feat: implement customer profile management and business application features

- Added customer profile controller with endpoints for retrieving, updating, and deactivating user profiles.
- Introduced address model and routes for managing user addresses.
- Created business application model and service for handling business applications, including approval and rejection processes.
- Developed business contact and credit account models to support business customer functionalities.
- Implemented settlement term model for managing payment terms.
- Added email templates for business application notifications (approved, rejected, received, and status changes).
- Enhanced validation schemas for customer and business inputs to ensure data integrity.
- Created unit tests for business application service and validation schemas to ensure functionality and correctness.
- Added migration scripts to set up new database tables and columns for customer and business features.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 14:58:26 +05:30
parent b6b345f245
commit fd4e324571
36 changed files with 406 additions and 6 deletions
+79
View File
@@ -0,0 +1,79 @@
# ZUMRI Customer and Business API
All endpoints require a Phase 1 access token and are mounted under both `/api` and `/api/v1`; new clients should use `/api/v1`. Examples use placeholders.
## Customer profile
- `GET /api/v1/profile/me`
- `PATCH /api/v1/profile/me`
Editable fields are first/last name, phone, date of birth, `en|si|ta` locale, theme, marketing email/push preference, in-app preference, and owned avatar/background upload IDs. Identity state, type, roles, permissions, tokens, passwords, business review data, partner ID, and credit settings are rejected.
```json
{"firstName":"<first-name>","preferredLanguage":"en","marketingEmailEnabled":false}
```
Media responses contain an upload ID and short-lived authorized URL, never an object key.
## Addresses
- `GET /api/v1/addresses`
- `POST /api/v1/addresses`
- `GET /api/v1/addresses/:id`
- `PATCH /api/v1/addresses/:id`
- `DELETE /api/v1/addresses/:id`
No user ID is accepted. Every query includes the authenticated owner. Setting either default flag clears the prior default under a transaction and User row lock. Deleting a default leaves that default unset. Future orders must snapshot addresses; they must never depend on mutable Address rows.
```json
{"label":"Home","recipientName":"<name>","phoneNumber":"<phone>","addressLine1":"<line>","city":"<city>","countryCode":"LK","isDefaultShipping":true}
```
## Account deactivation
`POST /api/v1/user/me/deactivate` with `{"confirmation":"DEACTIVATE","password":"<current-password>"}`. Password is required for password-based identities. Social-only identities require explicit confirmation. The operation sets DEACTIVATED, increments token version, revokes all sessions, and clears cookies. Self-reactivation is not supported; an authorized manual administrative process is required.
## Business applications
- `POST /api/v1/business/applications`
- `GET /api/v1/business/applications/me`
- `GET /api/v1/business/applications/:id`
Only verified ACTIVE customer accounts can apply. Ownership is applied to ID reads. Concurrent active applications are serialized by locking the applicant User.
```json
{"businessName":"<business>","legalName":"<legal-name>","registrationNumber":"<registration>","businessType":"<type>","contactEmail":"owner@example.com","contactPhone":"<phone>"}
```
Private supporting documents use the Phase 2 upload API with purpose `BUSINESS_REGISTRATION`, `TAX_DOCUMENT`, `IDENTITY_DOCUMENT`, or `OTHER_SUPPORTING_DOCUMENT`. Only the owner or a reviewer with `business.applications.review` can obtain a signed URL.
## Business self-service
- `GET /api/v1/business/me`
- `PATCH /api/v1/business/me`
- `POST /api/v1/business/me/contacts`
- `POST /api/v1/business/me/addresses`
Partner ID, review status, domain status, credit, settlement term, identity type, and approval metadata cannot be changed through self-service.
## Administrative review
- `GET /api/v1/admin/business/applications` — `business.applications.read`
- `GET /api/v1/admin/business/applications/:id` — same permission
- `POST /api/v1/admin/business/applications/:id/approve` — `business.applications.review`
- `POST /api/v1/admin/business/applications/:id/reject` — same permission; requires a reason
- `GET /api/v1/admin/business/accounts` — `business.accounts.read`
- `PATCH /api/v1/admin/business/accounts/:id/status` — `business.accounts.update`
Lists accept bounded `page`, `limit`, status/name filters and allowlisted sorting. Approval atomically locks the application/applicant, creates one profile and disabled credit account, assigns a `ZUM-BIZ-######` partner ID, changes account type to `business_customer`, revokes sessions, and marks the application approved.
## Credit and settlement primitives
- `PATCH /api/v1/admin/business/accounts/:id/credit` — `business.credit.manage`
- `PATCH /api/v1/admin/business/accounts/:id/settlement-term` — `business.settlement.manage`
```json
{"creditLimit":"100000.00","currency":"LKR","status":"ACTIVE"}
```
Credit uses `DECIMAL(15,2)`. There is intentionally no used or available balance until a future authoritative commerce/settlement ledger exists. Settlement terms are seeded configuration records only; this phase creates no invoices or settlements.
+8
View File
@@ -1,5 +1,13 @@
# ZUMRI Current Backend Status # ZUMRI Current Backend Status
## Phase 3 Completion Update
Completion date: 2026-09-03. Module 02 (customer profile/address management) is now approximately 88%; Module 13 (business accounts) is approximately 78%. Customer profile/preferences, structured owned addresses with transactional defaults, secure self-deactivation, business applications, permission-gated transactional approval, immutable partner identity, business profiles/contacts/addresses, domain status, DECIMAL credit configuration, and settlement-term primitives are implemented.
Security impact: all customer resource identity is derived from Phase 1 authentication; mutation schemas are strict; profile media is owner-validated; address/application IDOR is constrained in queries; approval/deactivation revoke sessions after identity-boundary changes; and business financial/review fields are excluded from self-service. Six models were added and two existing models extended. Customer/business and admin APIs are documented in `Documentation/API_CUSTOMER_BUSINESS.md`.
The complete mocked regression suite passes 12 suites/57 tests, and syntax validation passes 171 JavaScript files. The Phase 3 migration is forward-only and was not executed. Staging must resolve legacy business/profile/address pre-checks, seed/grant permissions, and validate MySQL concurrency plus Redis/S3/SMTP flows before Phase 4. See `Documentation/PHASE_3_CUSTOMER_BUSINESS_FOUNDATIONS.md`.
## Phase 2 Completion Update ## Phase 2 Completion Update
Completion date: 2026-09-03. Phase 2 hardens the existing shared-service foundation without adding commerce domains. Module 14 (notifications) is now approximately 72%; Module 19 (file/media) 82%; Module 20 (audit/config/logging) 68%; and Module 21 (background jobs) 78%. The document subsystem is approximately 82%. Completion date: 2026-09-03. Phase 2 hardens the existing shared-service foundation without adding commerce domains. Module 14 (notifications) is now approximately 72%; Module 19 (file/media) 82%; Module 20 (audit/config/logging) 68%; and Module 21 (background jobs) 78%. The document subsystem is approximately 82%.
@@ -0,0 +1,99 @@
# ZUMRI Phase 3 Customer and Business Foundations
## Objective
Complete customer and business account foundations needed before catalogue and commerce work, without implementing commerce.
## Existing Components Reused
Phase 1 authentication, account states, session revocation, canonical `business_customer`, permissions, and ReferenceNumber are reused. Phase 2 owned uploads, signed media, audit events, notification persistence, email queue, and templates remain shared boundaries.
## Customer Profile
Profile retains its existing phone/date/media/theme data and adds locale plus separate marketing email, marketing push, and in-app preferences. First/last name and email remain canonical User identity fields. Strict self endpoints derive identity from authentication and return no security or storage internals.
## Address Architecture
Address is structured and may belong to exactly one User or approved business profile. It supports international ISO alpha-2 country codes while retaining district/province fields useful in Sri Lanka. An ownership CHECK constraint and foreign keys enforce integrity.
## Default Address Rules
A User/business profile may use one shipping and one billing default, and one row may be both. Mutations serialize on the owner row and clear the prior default in the same transaction. Deletion leaves the default unset. Future commerce records must snapshot address values.
## Preferences
Marketing email and push choices are distinct from in-app preference and Phase 2 mandatory security-message policy. Security messages cannot be disabled through these fields.
## Account Deactivation
Self-deactivation is a soft identity transition. Password accounts confirm the current password; social-only accounts provide the explicit DEACTIVATE confirmation. The transaction increments token version and revokes every session. Login remains denied and self-reactivation is deferred.
## Business Application Architecture
Applications preserve review history independently of the approved profile. Only verified ACTIVE customers can submit, and the applicant row lock prevents concurrent active submissions. States are PENDING, UNDER_REVIEW, APPROVED, REJECTED, and CANCELLED.
## Business Approval Workflow
Permission-gated approval locks the application and applicant, validates transition, generates one partner sequence, creates BusinessCustomer and a disabled zero-limit credit account, changes the canonical account type, increments token version/revokes sessions, and records approval. Double approval fails safely.
## Business Profile
The existing BusinessCustomer table is retained as the approved business profile. It now includes legal/tax/web data, immutable unique partner ID, separate ACTIVE/SUSPENDED/CLOSED domain status, approval metadata, and settlement reference. Self-editing is allowlisted.
## Partner Identity
Partner identity is generated transactionally from ReferenceNumber as `ZUM-BIZ-######`. It is unique, immutable through public APIs, safe to expose, and never previewed through GET.
## Business Contacts
BusinessContact holds non-authenticating contact people. A transaction serializes primary-contact changes without creating User identities or organization/team accounts.
## Business Documents
Applications reuse private Phase 2 Upload records and its MIME/signature/checksum controls. Defined purposes identify registration, tax, identity, and supporting records. Review permission extends signed-read access; storage keys remain private.
## Business Status
Business domain status is separate from User account status. Suspending a business capability does not automatically suspend login identity.
## Credit Foundation
BusinessCreditAccount stores currency, `DECIMAL(15,2)` limit, and DISABLED/ACTIVE/SUSPENDED configuration. Only privileged administration may change it and every change is audited. No used-credit field or fabricated availability is present.
## Settlement Terms
SettlementTerm provides code, display name, day count, and active state. PREPAID, NET_7, NET_14, and NET_30 initial records are migration data, not hardcoded behavior. No billing, invoice, settlement, or product-pricing logic exists.
## Ownership
Customer profile/address/application queries derive the authenticated User and constrain IDs at query time. Business self-service resolves the profile by authenticated owner. Reviewer and administration routes require explicit permissions.
## Permissions
Added `business.applications.read`, `business.applications.review`, `business.accounts.read`, `business.accounts.update`, `business.credit.manage`, and `business.settlement.manage`. SUPER_ADMIN retains the established bypass.
## Audit Events
Profile, address, deactivation, application review, profile/status, credit, and settlement changes emit Phase 2 events with stable names and IDs. Full addresses, documents, credentials, and reviewer internals are not placed in metadata.
## Notifications
Application receipt/approval/rejection and business status templates use Notification/UserNotification and the Phase 2 email queue. Controllers do not call SMTP. Push delivery remains deferred.
## Database Changes
The new forward-only Phase 3 migration extends Profile and BusinessCustomer, creates addresses, business applications/contacts/credit accounts and settlement terms, adds targeted indexes/constraints, and seeds initial settlement configuration.
Pre-check existing `business_customer` users, duplicate registration or partner IDs, invalid/null Profile relationships, legacy Customer address strings requiring manual migration, existing BusinessCustomer records that need partner/approval backfill, and duplicate profiles. No legacy data is silently deleted.
## Tests
New unit tests cover strict profile/business mass assignment, locale/address validation, deactivation confirmation, business application eligibility/duplicate prevention, rejection requirements, and credit validation. Existing Phase 0-2 suites remain mandatory.
## Remaining Known Issues
The migration has not run against staging. Legacy BusinessCustomer rows require an explicit partner/approval backfill before making new columns universally non-null. Real MySQL lock/concurrency behavior, Redis queues, SMTP notifications, S3 documents, and migration constraints need staging tests. Business document-to-application linking and administrative settlement-term CRUD may be added when real operational requirements are known.
## Phase 4 Prerequisites
Back up and restore staging data, complete pre-check/backfill decisions, apply migrations in order, seed/grant Phase 3 permissions, run concurrent application/default-address tests on MySQL, and verify one application approval plus notification flow with non-production integrations.
+3
View File
@@ -17,4 +17,7 @@ module.exports = {
DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete", DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete",
NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read", NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.read",
AUDIT_READ: "audit.read", QUEUES_READ: "queues.read", AUDIT_READ: "audit.read", QUEUES_READ: "queues.read",
BUSINESS_APPLICATIONS_READ: "business.applications.read", BUSINESS_APPLICATIONS_REVIEW: "business.applications.review",
BUSINESS_ACCOUNTS_READ: "business.accounts.read", BUSINESS_ACCOUNTS_UPDATE: "business.accounts.update",
BUSINESS_CREDIT_MANAGE: "business.credit.manage", BUSINESS_SETTLEMENT_MANAGE: "business.settlement.manage",
}; };
+8
View File
@@ -0,0 +1,8 @@
const crypto=require("crypto"); const db=require("../models"); const {logActivity}=require("../services/activity.service");
const map=(b)=>({label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,is_default_shipping:b.isDefaultShipping,is_default_billing:b.isDefaultBilling});
const setDefaults=async(userId,id,b,t)=>{if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{user_id:userId},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{user_id:userId},transaction:t});};
exports.list=async(req,res,next)=>{try{return res.json({success:true,data:await db.Address.findAll({where:{user_id:req.user.id},order:[["createdAt","ASC"]]})});}catch(e){return next(e);}};
exports.get=async(req,res,next)=>{try{const row=await db.Address.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Address not found"}});return res.json({success:true,data:row});}catch(e){return next(e);}};
exports.create=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{await db.User.findByPk(req.user.id,{transaction:t,lock:t.LOCK.UPDATE});await setDefaults(req.user.id,null,req.body,t);row=await db.Address.create({id:crypto.randomUUID(),user_id:req.user.id,...map(req.body)},{transaction:t});});await logActivity({user:req.user,type:"ADDRESS_CREATED",module:"Customer",description:"Customer address created",targetType:"ADDRESS",targetId:row.id,requestId:req.id});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
exports.update=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{await db.User.findByPk(req.user.id,{transaction:t,lock:t.LOCK.UPDATE});row=await db.Address.findOne({where:{id:req.params.id,user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Address not found"),{status:404,code:"NOT_FOUND"});await setDefaults(req.user.id,row.id,req.body,t);await row.update(map(req.body),{transaction:t});});await logActivity({user:req.user,type:"ADDRESS_UPDATED",module:"Customer",description:"Customer address updated",targetType:"ADDRESS",targetId:row.id,requestId:req.id});return res.json({success:true,data:row});}catch(e){return next(e);}};
exports.remove=async(req,res,next)=>{try{const count=await db.Address.destroy({where:{id:req.params.id,user_id:req.user.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Address not found"}});await logActivity({user:req.user,type:"ADDRESS_DELETED",module:"Customer",description:"Customer address deleted",targetType:"ADDRESS",targetId:req.params.id,requestId:req.id});return res.status(204).end();}catch(e){return next(e);}};
+20
View File
@@ -0,0 +1,20 @@
const crypto=require("crypto"); const {Op}=require("sequelize"); const db=require("../models"); const service=require("../services/business/business.service"); const {logActivity}=require("../services/activity.service"); const notifications=require("../services/notification/notification.service");
const safeApp=a=>({id:a.id,businessName:a.business_name,legalName:a.legal_name,registrationNumber:a.registration_number,taxNumber:a.tax_number,businessType:a.business_type,contactEmail:a.contact_email,contactPhone:a.contact_phone,website:a.website,status:a.status,reviewedAt:a.reviewed_at,rejectionReason:a.rejection_reason,createdAt:a.createdAt});
const safeProfile=p=>({id:p.business_customer_id,businessName:p.businessName,legalName:p.legalName,registrationNumber:p.businessRegistrationNumber,taxNumber:p.taxNumber,businessType:p.businessType,contactEmail:p.businessEmail,contactPhone:p.phoneNumber,website:p.website,partnerId:p.partnerId,status:p.status,approvedAt:p.approvedAt,settlementTermId:p.settlement_term_id});
exports.apply=async(req,res,next)=>{try{const app=await service.apply(req.user.id,req.body);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_SUBMITTED",module:"Business",description:"Business application submitted",targetType:"BUSINESS_APPLICATION",targetId:app.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_SUBMITTED",user:req.user,headline:"Business application received",description:"Your application is awaiting review.",templateKey:"businessApplicationReceived",variables:{firstName:req.user.firstName,businessName:app.business_name,applicationId:app.id},correlationId:req.id}).catch(()=>undefined);return res.status(201).json({success:true,data:safeApp(app)});}catch(e){return next(e);}};
exports.myApplications=async(req,res,next)=>{try{return res.json({success:true,data:(await db.BusinessApplication.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})).map(safeApp)});}catch(e){return next(e);}};
exports.getApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
exports.getMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},include:[{model:db.BusinessContact,as:"contacts"},{model:db.Address,as:"addresses"},{model:db.BusinessCreditAccount,as:"creditAccount",attributes:["currency","credit_limit","status"]},{model:db.SettlementTerm,as:"settlementTerm"}]});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});return res.json({success:true,data:{...safeProfile(p),contacts:p.contacts,addresses:p.addresses,creditAccount:p.creditAccount,settlementTerm:p.settlementTerm}});}catch(e){return next(e);}};
exports.updateMe=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id}});if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business profile not found"}});const b=req.body;await p.update({...(b.businessName!==undefined&&{businessName:b.businessName}),...(b.legalName!==undefined&&{legalName:b.legalName}),...(b.taxNumber!==undefined&&{taxNumber:b.taxNumber}),...(b.businessType!==undefined&&{businessType:b.businessType}),...(b.contactEmail!==undefined&&{businessEmail:b.contactEmail}),...(b.contactPhone!==undefined&&{phoneNumber:b.contactPhone}),...(b.website!==undefined&&{website:b.website})});await logActivity({user:req.user,type:"BUSINESS_PROFILE_UPDATED",module:"Business",description:"Business profile updated",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id});return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}};
exports.addContact=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});if(req.body.isPrimary)await db.BusinessContact.update({is_primary:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.BusinessContact.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,name:req.body.name,title:req.body.title,email:req.body.email,phone:req.body.phone,is_primary:Boolean(req.body.isPrimary)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
exports.addAddress=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findOne({where:{user_id:req.user.id},transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business profile not found"),{status:404,code:"NOT_FOUND"});const b=req.body;if(b.isDefaultShipping)await db.Address.update({is_default_shipping:false},{where:{business_profile_id:p.business_customer_id},transaction:t});if(b.isDefaultBilling)await db.Address.update({is_default_billing:false},{where:{business_profile_id:p.business_customer_id},transaction:t});row=await db.Address.create({id:crypto.randomUUID(),business_profile_id:p.business_customer_id,label:b.label,recipient_name:b.recipientName,phone_number:b.phoneNumber,address_line_1:b.addressLine1,address_line_2:b.addressLine2,city:b.city,district:b.district,province:b.province,postal_code:b.postalCode,country_code:b.countryCode,address_type:"DELIVERY",is_default_shipping:Boolean(b.isDefaultShipping),is_default_billing:Boolean(b.isDefaultBilling)},{transaction:t});});return res.status(201).json({success:true,data:row});}catch(e){return next(e);}};
const paging=q=>({limit:Math.min(Number(q.limit)||20,100),offset:(Math.max(Number(q.page)||1,1)-1)*Math.min(Number(q.limit)||20,100)});
exports.adminListApplications=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.businessName)where.business_name={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const result=await db.BusinessApplication.findAndCountAll({where,limit,offset,order:[[req.query.sort==="status"?"status":"createdAt",req.query.direction==="asc"?"ASC":"DESC"]]});return res.json({success:true,data:result.rows.map(safeApp),pagination:{total:result.count,limit,offset}});}catch(e){return next(e);}};
exports.adminGetApplication=async(req,res,next)=>{try{const a=await db.BusinessApplication.findByPk(req.params.id);if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Application not found"}});return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
exports.approve=async(req,res,next)=>{try{const {application,profile}=await service.approve(req.params.id,req.user.id);const applicant=await db.User.findByPk(application.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_APPROVED",module:"Business",description:"Business application approved",targetType:"BUSINESS_APPLICATION",targetId:application.id,requestId:req.id,metadata:{partnerId:profile.partnerId}});await notifications.publish({type:"BUSINESS_APPLICATION_APPROVED",user:applicant,headline:"Business application approved",description:"Your business account is active.",templateKey:"businessApplicationApproved",variables:{firstName:applicant.firstName,businessName:profile.businessName,partnerId:profile.partnerId},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:{application:safeApp(application),profile:safeProfile(profile)}});}catch(e){return next(e);}};
exports.reject=async(req,res,next)=>{try{const a=await service.reject(req.params.id,req.user.id,req.body.reason);const applicant=await db.User.findByPk(a.user_id);await logActivity({user:req.user,type:"BUSINESS_APPLICATION_REJECTED",module:"Business",description:"Business application rejected",targetType:"BUSINESS_APPLICATION",targetId:a.id,requestId:req.id});await notifications.publish({type:"BUSINESS_APPLICATION_REJECTED",user:applicant,headline:"Business application reviewed",description:"Your application was not approved.",templateKey:"businessApplicationRejected",variables:{firstName:applicant.firstName,businessName:a.business_name,reason:a.rejection_reason},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeApp(a)});}catch(e){return next(e);}};
exports.adminListBusinesses=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;if(req.query.partnerId)where.partnerId=req.query.partnerId;if(req.query.businessName)where.businessName={[Op.like]:`%${req.query.businessName.slice(0,100)}%`};const {limit,offset}=paging(req.query);const x=await db.BusinessCustomer.findAndCountAll({where,limit,offset,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows.map(safeProfile),pagination:{total:x.count,limit,offset}});}catch(e){return next(e);}};
exports.setCredit=async(req,res,next)=>{try{let row,old;await db.sequelize.transaction(async t=>{const p=await db.BusinessCustomer.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!p)throw Object.assign(new Error("Business not found"),{status:404,code:"NOT_FOUND"});row=await db.BusinessCreditAccount.findOne({where:{business_profile_id:p.business_customer_id},transaction:t,lock:t.LOCK.UPDATE});old=row.credit_limit;await row.update({credit_limit:req.body.creditLimit,currency:req.body.currency,status:req.body.status},{transaction:t});});await logActivity({user:req.user,type:"BUSINESS_CREDIT_LIMIT_CHANGED",module:"Business",description:"Business credit configuration changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{oldValue:String(old),newValue:String(req.body.creditLimit),currency:req.body.currency}});return res.json({success:true,data:row});}catch(e){return next(e);}};
exports.setSettlement=async(req,res,next)=>{try{const term=await db.SettlementTerm.findOne({where:{id:req.body.settlementTermId,is_active:true}});if(!term)return res.status(400).json({success:false,error:{code:"INVALID_SETTLEMENT_TERM",message:"Settlement term unavailable"}});const [count]=await db.BusinessCustomer.update({settlement_term_id:term.id},{where:{business_customer_id:req.params.id}});if(!count)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await logActivity({user:req.user,type:"BUSINESS_SETTLEMENT_TERM_CHANGED",module:"Business",description:"Business settlement term changed",targetType:"BUSINESS_PROFILE",targetId:req.params.id,requestId:req.id,metadata:{settlementTermId:term.id}});return res.json({success:true});}catch(e){return next(e);}};
exports.setStatus=async(req,res,next)=>{try{const p=await db.BusinessCustomer.findByPk(req.params.id);if(!p)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Business not found"}});await p.update({status:req.body.status});await logActivity({user:req.user,type:"BUSINESS_STATUS_CHANGED",module:"Business",description:"Business domain status changed",targetType:"BUSINESS_PROFILE",targetId:p.business_customer_id,requestId:req.id,metadata:{status:req.body.status}});const owner=await db.User.findByPk(p.user_id);if(owner)await notifications.publish({type:"BUSINESS_STATUS_CHANGED",user:owner,headline:"Business account status changed",description:`Your business account is now ${p.status}.`,templateKey:"businessAccountStatusChanged",variables:{firstName:owner.firstName,status:p.status},correlationId:req.id}).catch(()=>undefined);return res.json({success:true,data:safeProfile(p)});}catch(e){return next(e);}};
module.exports.safeApp=safeApp;module.exports.safeProfile=safeProfile;
@@ -0,0 +1,11 @@
const db = require("../models");
const { checkPassword } = require("../utils/hashPassword.util");
const { revokeAllUserSessions } = require("../services/auth/session.service");
const { logActivity } = require("../services/activity.service");
const storage = require("../services/storage/storage.service");
const media = async (id, userId) => { if (!id || id === "N/A") return null; const upload = await db.Upload.findOne({ where: { id, owner_id: userId, status: "AVAILABLE" } }); if (!upload) return null; const expiresIn = Number(process.env.S3_SIGNED_URL_TTL_SECONDS || 900); return { id: upload.id, url: await storage.createSignedDownloadUrl(upload.file_path, expiresIn), expiresIn }; };
const response = async (user, profile) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, phoneNumber: profile.phone_number, dateOfBirth: profile.dob, preferredLanguage: profile.preferred_language, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt), avatar: await media(profile.profilePicture_id, user.id), background: await media(profile.backgroundImage_id, user.id), preferences: { theme: profile.theme, marketingEmailEnabled: profile.marketing_email_enabled, marketingPushEnabled: profile.marketing_push_enabled, inAppNotificationsEnabled: profile.in_app_notifications_enabled } });
exports.getMe = async (req, res, next) => { try { const user = await db.User.findByPk(req.user.id, { include: [{ model: db.Profile, as: "profile" }] }); if (!user?.profile) return res.status(404).json({ success: false, error: { code: "PROFILE_NOT_FOUND", message: "Profile not found" } }); return res.json({ success: true, data: await response(user, user.profile) }); } catch (e) { return next(e); } };
exports.updateMe = async (req, res, next) => { try { let user, profile; await db.sequelize.transaction(async transaction => { user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE }); profile = await db.Profile.findOne({ where: { user_id: req.user.id }, transaction, lock: transaction.LOCK.UPDATE }); const b=req.body; for(const id of [b.avatarUploadId,b.backgroundUploadId].filter(Boolean)){const owned=await db.Upload.findOne({where:{id,owner_id:req.user.id,status:"AVAILABLE"},transaction});if(!owned)throw Object.assign(new Error("Profile media must be an available owned upload"),{status:400,code:"INVALID_PROFILE_MEDIA"});} await user.update({ ...(b.firstName !== undefined && { firstName:b.firstName }), ...(b.lastName !== undefined && { lastName:b.lastName }) }, { transaction }); await profile.update({ ...(b.phoneNumber !== undefined && { phone_number:b.phoneNumber }), ...(b.dateOfBirth !== undefined && { dob:b.dateOfBirth }), ...(b.preferredLanguage !== undefined && { preferred_language:b.preferredLanguage }), ...(b.theme !== undefined && { theme:b.theme }), ...(b.marketingEmailEnabled !== undefined && { marketing_email_enabled:b.marketingEmailEnabled }), ...(b.marketingPushEnabled !== undefined && { marketing_push_enabled:b.marketingPushEnabled }), ...(b.inAppNotificationsEnabled !== undefined && { in_app_notifications_enabled:b.inAppNotificationsEnabled, notificationsEnabled:b.inAppNotificationsEnabled }), ...(b.avatarUploadId !== undefined && { profilePicture_id:b.avatarUploadId }), ...(b.backgroundUploadId !== undefined && { backgroundImage_id:b.backgroundUploadId }) }, { transaction }); }); await logActivity({ user:req.user, type:"PROFILE_UPDATED", module:"Customer", description:"Customer profile updated", targetType:"USER", targetId:req.user.id, requestId:req.id }); return res.json({ success:true, data:await response(user,profile) }); } catch(e){ return next(e); } };
exports.deactivate = async (req,res,next) => { try { await db.sequelize.transaction(async transaction => { const user=await db.User.findByPk(req.user.id,{transaction,lock:transaction.LOCK.UPDATE}); if(user.password && (!req.body.password || !(await checkPassword(req.body.password,user.password)))) throw Object.assign(new Error("Password confirmation failed"),{status:403,code:"INVALID_CONFIRMATION"}); await user.update({accountStatus:"DEACTIVATED",tokenVersion:user.tokenVersion+1},{transaction}); await revokeAllUserSessions(user.id,"SELF_DEACTIVATED",transaction); }); await logActivity({user:req.user,type:"ACCOUNT_DEACTIVATED",module:"Identity",description:"Customer deactivated account",targetType:"USER",targetId:req.user.id,requestId:req.id}); res.clearCookie("access_token"); res.clearCookie("refresh_token",{path:"/api"}); return res.json({success:true,message:"Account deactivated"}); } catch(e){return next(e);} };
+2 -1
View File
@@ -2,7 +2,8 @@ const db = require("../models");
const storage = require("../services/storage/storage.service"); const storage = require("../services/storage/storage.service");
const { validateUpload } = require("../services/storage/file-validation.service"); const { validateUpload } = require("../services/storage/file-validation.service");
const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "super_admin" || (user.accountType === "admin" && (user.permissions || []).includes("media.read")); const BUSINESS_DOCUMENT_PURPOSES = new Set(["BUSINESS_REGISTRATION", "TAX_DOCUMENT", "IDENTITY_DOCUMENT", "OTHER_SUPPORTING_DOCUMENT"]);
const canAccess = (user, upload) => upload.visibility === "PUBLIC" || upload.owner_id === user.id || user.accountType === "superadmin" || (user.permissions || []).includes("media.read") || (BUSINESS_DOCUMENT_PURPOSES.has(upload.use_for) && (user.permissions || []).includes("business.applications.review"));
exports.uploadFile = async (req, res, next) => { exports.uploadFile = async (req, res, next) => {
let objectKey; let objectKey;
+5
View File
@@ -43,6 +43,11 @@ db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes);
db.UserIdentity = require("./user/userIdentity.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.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);
// Uploads // Uploads
db.Upload = require("./upload/upload.model")(sequelize, DataTypes); db.Upload = require("./upload/upload.model")(sequelize, DataTypes);
+13
View File
@@ -0,0 +1,13 @@
module.exports = (sequelize, DataTypes) => {
const Address = sequelize.define("Address", {
id: { type: DataTypes.STRING, primaryKey: true },
user_id: { type: DataTypes.STRING, allowNull: true },
business_profile_id: { type: DataTypes.STRING, allowNull: true },
label: { type: DataTypes.STRING(50), allowNull: false }, recipient_name: { type: DataTypes.STRING(150), allowNull: false }, phone_number: { type: DataTypes.STRING(30), allowNull: false },
address_line_1: { type: DataTypes.STRING(255), allowNull: false }, address_line_2: DataTypes.STRING(255), city: { type: DataTypes.STRING(100), allowNull: false }, district: DataTypes.STRING(100), province: DataTypes.STRING(100), postal_code: DataTypes.STRING(20),
country_code: { type: DataTypes.STRING(2), allowNull: false }, address_type: { type: DataTypes.ENUM("CUSTOMER", "REGISTERED", "BILLING", "DELIVERY"), allowNull: false, defaultValue: "CUSTOMER" },
is_default_shipping: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, is_default_billing: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
}, { tableName: "addresses", timestamps: true });
Address.associate = (models) => { Address.belongsTo(models.User, { foreignKey: "user_id", as: "user" }); Address.belongsTo(models.BusinessCustomer, { foreignKey: "business_profile_id", as: "businessProfile" }); };
return Address;
};
@@ -0,0 +1,9 @@
module.exports = (sequelize, DataTypes) => {
const Model = sequelize.define("BusinessApplication", {
id: { type: DataTypes.STRING, primaryKey: true }, user_id: { type: DataTypes.STRING, allowNull: false },
business_name: { type: DataTypes.STRING(180), allowNull: false }, legal_name: { type: DataTypes.STRING(180), allowNull: false }, registration_number: { type: DataTypes.STRING(100), allowNull: false }, tax_number: DataTypes.STRING(100), business_type: { type: DataTypes.STRING(80), allowNull: false }, contact_email: { type: DataTypes.STRING, allowNull: false }, contact_phone: { type: DataTypes.STRING(30), allowNull: false }, website: DataTypes.STRING,
status: { type: DataTypes.ENUM("PENDING", "UNDER_REVIEW", "APPROVED", "REJECTED", "CANCELLED"), allowNull: false, defaultValue: "PENDING" }, reviewed_by: DataTypes.STRING, reviewed_at: DataTypes.DATE, rejection_reason: DataTypes.STRING(500),
}, { tableName: "business_applications", timestamps: true });
Model.associate = (models) => { Model.belongsTo(models.User, { foreignKey: "user_id", as: "applicant" }); Model.belongsTo(models.User, { foreignKey: "reviewed_by", as: "reviewer", constraints: false }); };
return Model;
};
+4
View File
@@ -0,0 +1,4 @@
module.exports = (sequelize, DataTypes) => {
const Model = sequelize.define("BusinessContact", { id: { type: DataTypes.STRING, primaryKey: true }, business_profile_id: { type: DataTypes.STRING, allowNull: false }, name: { type: DataTypes.STRING(150), allowNull: false }, title: DataTypes.STRING(100), email: DataTypes.STRING, phone: DataTypes.STRING(30), is_primary: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false } }, { tableName: "business_contacts", timestamps: true });
Model.associate = (models) => Model.belongsTo(models.BusinessCustomer, { foreignKey: "business_profile_id", as: "businessProfile" }); return Model;
};
@@ -0,0 +1,4 @@
module.exports = (sequelize, DataTypes) => {
const Model = sequelize.define("BusinessCreditAccount", { id: { type: DataTypes.STRING, primaryKey: true }, business_profile_id: { type: DataTypes.STRING, allowNull: false, unique: true }, currency: { type: DataTypes.STRING(3), allowNull: false, defaultValue: "LKR" }, credit_limit: { type: DataTypes.DECIMAL(15,2), allowNull: false, defaultValue: 0 }, status: { type: DataTypes.ENUM("DISABLED", "ACTIVE", "SUSPENDED"), allowNull: false, defaultValue: "DISABLED" } }, { tableName: "business_credit_accounts", timestamps: true });
Model.associate = (models) => Model.belongsTo(models.BusinessCustomer, { foreignKey: "business_profile_id", as: "businessProfile" }); return Model;
};
+13 -1
View File
@@ -21,7 +21,7 @@ module.exports = (sequelize, DataTypes) => {
}, },
businessRegistrationNumber: { businessRegistrationNumber: {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: false, allowNull: false, unique: true,
}, },
businessType: { businessType: {
type: DataTypes.STRING, type: DataTypes.STRING,
@@ -47,6 +47,14 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: true,
}, },
legalName: DataTypes.STRING,
taxNumber: DataTypes.STRING,
website: DataTypes.STRING,
partnerId: { type: DataTypes.STRING, allowNull: false, unique: true },
status: { type: DataTypes.ENUM("ACTIVE", "SUSPENDED", "CLOSED"), allowNull: false, defaultValue: "ACTIVE" },
approvedAt: { type: DataTypes.DATE, allowNull: false },
approvedBy: { type: DataTypes.STRING, allowNull: false },
settlement_term_id: { type: DataTypes.STRING, allowNull: true },
}, },
{ {
tableName: "business_customers", tableName: "business_customers",
@@ -59,6 +67,10 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "user_id", foreignKey: "user_id",
as: "user", as: "user",
}); });
BusinessCustomer.hasMany(db.BusinessContact, { foreignKey: "business_profile_id", as: "contacts" });
BusinessCustomer.hasMany(db.Address, { foreignKey: "business_profile_id", as: "addresses" });
BusinessCustomer.hasOne(db.BusinessCreditAccount, { foreignKey: "business_profile_id", as: "creditAccount" });
BusinessCustomer.belongsTo(db.SettlementTerm, { foreignKey: "settlement_term_id", as: "settlementTerm" });
}; };
return BusinessCustomer; return BusinessCustomer;
+5 -1
View File
@@ -47,7 +47,11 @@ module.exports = (sequelize, DataTypes) => {
phone_number:{ phone_number:{
type: DataTypes.STRING, type: DataTypes.STRING,
allowNull: true, allowNull: true,
} },
preferred_language: { type: DataTypes.ENUM("en", "si", "ta"), allowNull: false, defaultValue: "en" },
marketing_email_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
marketing_push_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
in_app_notifications_enabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
}, },
{ {
tableName: "profiles", tableName: "profiles",
+1
View File
@@ -0,0 +1 @@
module.exports = (sequelize, DataTypes) => sequelize.define("SettlementTerm", { id: { type: DataTypes.STRING, primaryKey: true }, code: { type: DataTypes.STRING(30), allowNull: false, unique: true }, name: { type: DataTypes.STRING(100), allowNull: false }, days: { type: DataTypes.INTEGER, allowNull: false }, is_active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true } }, { tableName: "settlement_terms", timestamps: true });
+2
View File
@@ -88,6 +88,8 @@ module.exports = (sequelize, DataTypes) => {
User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" }); User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" });
User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" }); User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" });
User.hasMany(db.UserRole, { foreignKey: "user_id", as: "userRoles" }); User.hasMany(db.UserRole, { foreignKey: "user_id", as: "userRoles" });
User.hasMany(db.Address, { foreignKey: "user_id", as: "addresses" });
User.hasMany(db.BusinessApplication, { foreignKey: "user_id", as: "businessApplications" });
}; };
return User; return User;
+8 -3
View File
@@ -1,3 +1,8 @@
const { Queue } = require("bullmq"); const options = { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } };
const connection = require("../config/redisClient"); if (process.env.NODE_ENV === "test") {
module.exports = new Queue("email-delivery", { connection, defaultJobOptions: { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } } }); module.exports = { name: "email-delivery", opts: { defaultJobOptions: options }, add: async (_name, _data, jobOptions) => ({ id: jobOptions?.jobId }), close: async () => undefined };
} else {
const { Queue } = require("bullmq");
const connection = require("../config/redisClient");
module.exports = new Queue("email-delivery", { connection, defaultJobOptions: options });
}
+2
View File
@@ -0,0 +1,2 @@
const r=require("express").Router(); const c=require("../controllers/address.controller"); const {authenticate}=require("../middleware/auth.middleware"); const validate=require("../middleware/validate.middleware"); const s=require("../validation/customer.schemas");
r.use(authenticate); r.get("/",c.list);r.post("/",validate(s.addressCreate),c.create);r.get("/:id",c.get);r.patch("/:id",validate(s.addressUpdate),c.update);r.delete("/:id",c.remove);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router();const c=require("../controllers/business.controller");const {authenticate}=require("../middleware/auth.middleware");const {checkPermission}=require("../middleware/permission.middleware");const validate=require("../middleware/validate.middleware");const s=require("../validation/business.schemas");r.use(authenticate);r.get("/applications",checkPermission("business.applications.read",{custom:true}),c.adminListApplications);r.get("/applications/:id",checkPermission("business.applications.read",{custom:true}),c.adminGetApplication);r.post("/applications/:id/approve",checkPermission("business.applications.review",{custom:true}),c.approve);r.post("/applications/:id/reject",checkPermission("business.applications.review",{custom:true}),validate(s.reject),c.reject);r.get("/accounts",checkPermission("business.accounts.read",{custom:true}),c.adminListBusinesses);r.patch("/accounts/:id/status",checkPermission("business.accounts.update",{custom:true}),validate(s.status),c.setStatus);r.patch("/accounts/:id/credit",checkPermission("business.credit.manage",{custom:true}),validate(s.credit),c.setCredit);r.patch("/accounts/:id/settlement-term",checkPermission("business.settlement.manage",{custom:true}),validate(s.settlement),c.setSettlement);module.exports=r;
+1
View File
@@ -0,0 +1 @@
const r=require("express").Router();const c=require("../controllers/business.controller");const {authenticate}=require("../middleware/auth.middleware");const validate=require("../middleware/validate.middleware");const s=require("../validation/business.schemas");const customerSchemas=require("../validation/customer.schemas");r.use(authenticate);r.post("/applications",validate(s.application),c.apply);r.get("/applications/me",c.myApplications);r.get("/applications/:id",c.getApplication);r.get("/me",c.getMe);r.patch("/me",validate(s.businessUpdate),c.updateMe);r.post("/me/contacts",validate(s.contact),c.addContact);r.post("/me/addresses",validate(customerSchemas.addressCreate),c.addAddress);module.exports=r;
+6
View File
@@ -23,6 +23,9 @@ const notificationRoutes = require("./notification.routes");
const adminAuthRoutes = require("./adminAuth.routes"); const adminAuthRoutes = require("./adminAuth.routes");
const riderAuthRoutes = require("./riderAuth.routes"); const riderAuthRoutes = require("./riderAuth.routes");
const adminUserRoutes = require("./adminUser.routes"); const adminUserRoutes = require("./adminUser.routes");
const addressRoutes = require("./address.routes");
const businessRoutes = require("./business.routes");
const adminBusinessRoutes = require("./adminBusiness.routes");
const router = express.Router(); const router = express.Router();
@@ -38,5 +41,8 @@ router.use("/permissions", permissionRoutes);
router.use("/admin/auth", adminAuthRoutes); router.use("/admin/auth", adminAuthRoutes);
router.use("/rider/auth", riderAuthRoutes); router.use("/rider/auth", riderAuthRoutes);
router.use("/admin/users", adminUserRoutes); router.use("/admin/users", adminUserRoutes);
router.use("/addresses", addressRoutes);
router.use("/business", businessRoutes);
router.use("/admin/business", adminBusinessRoutes);
module.exports = router; module.exports = router;
+5
View File
@@ -24,6 +24,11 @@ const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const validate = require("../middleware/validate.middleware"); const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas"); const schemas = require("../validation/auth.schemas");
const { requireOwnership } = require("../middleware/permission.middleware"); const { requireOwnership } = require("../middleware/permission.middleware");
const customerProfile = require("../controllers/customerProfile.controller");
const customerSchemas = require("../validation/customer.schemas");
router.get("/me", authenticate, customerProfile.getMe);
router.patch("/me", authenticate, validate(customerSchemas.profileUpdate), customerProfile.updateMe);
router.post("/req-reset-password", sensitiveLimiter, validate(schemas.forgot), authController.forgotPassword); router.post("/req-reset-password", sensitiveLimiter, validate(schemas.forgot), authController.forgotPassword);
+4
View File
@@ -18,9 +18,13 @@ const {
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware"); const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
const PERMISSIONS = require("../constants/permissions"); const PERMISSIONS = require("../constants/permissions");
const customerProfile = require("../controllers/customerProfile.controller");
const validate = require("../middleware/validate.middleware");
const customerSchemas = require("../validation/customer.schemas");
router.get("/me", authenticate, userController.getCurrentUser); router.get("/me", authenticate, userController.getCurrentUser);
router.patch("/me", authenticate, userController.updateCurrentUser); router.patch("/me", authenticate, userController.updateCurrentUser);
router.post("/me/deactivate", authenticate, validate(customerSchemas.deactivate), customerProfile.deactivate);
router.post( router.post(
@@ -0,0 +1,7 @@
const crypto=require("crypto"); const {Op}=require("sequelize"); const db=require("../../models"); const {nextSequence}=require("../../utils/referenceNumber.util");
const { revokeAllUserSessions } = require("../auth/session.service");
const normalize=(b)=>({business_name:b.businessName,legal_name:b.legalName,registration_number:b.registrationNumber,tax_number:b.taxNumber,business_type:b.businessType,contact_email:b.contactEmail,contact_phone:b.contactPhone,website:b.website});
const apply=async(userId,body)=>db.sequelize.transaction(async t=>{const user=await db.User.findByPk(userId,{transaction:t,lock:t.LOCK.UPDATE});if(!user||user.accountStatus!=="ACTIVE"||!user.emailVerifiedAt||user.accountType!=="customer")throw Object.assign(new Error("Only verified active customers may apply"),{status:403,code:"NOT_ELIGIBLE"});const active=await db.BusinessApplication.findOne({where:{user_id:userId,status:{[Op.in]:["PENDING","UNDER_REVIEW"]}},transaction:t,lock:t.LOCK.UPDATE});if(active)throw Object.assign(new Error("An active application already exists"),{status:409,code:"ACTIVE_APPLICATION_EXISTS"});return db.BusinessApplication.create({id:crypto.randomUUID(),user_id:userId,...normalize(body),status:"PENDING"},{transaction:t});});
const approve=async(id,reviewerId)=>db.sequelize.transaction(async t=>{const application=await db.BusinessApplication.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!application)throw Object.assign(new Error("Application not found"),{status:404,code:"NOT_FOUND"});if(!["PENDING","UNDER_REVIEW"].includes(application.status))throw Object.assign(new Error("Application cannot be approved from its current state"),{status:409,code:"INVALID_TRANSITION"});const user=await db.User.findByPk(application.user_id,{transaction:t,lock:t.LOCK.UPDATE});const existing=await db.BusinessCustomer.findOne({where:{user_id:application.user_id},transaction:t,lock:t.LOCK.UPDATE});if(existing)throw Object.assign(new Error("Business profile already exists"),{status:409,code:"BUSINESS_EXISTS"});const number=await nextSequence("business_partner",t);const partnerId=`ZUM-BIZ-${String(number).padStart(6,"0")}`;const profile=await db.BusinessCustomer.create({business_customer_id:`biz_${crypto.randomUUID()}`,user_id:application.user_id,businessName:application.business_name,legalName:application.legal_name,businessRegistrationNumber:application.registration_number,taxNumber:application.tax_number,businessType:application.business_type,contactName:application.business_name,phoneNumber:application.contact_phone,businessEmail:application.contact_email,website:application.website,expectedMonthlyVolume:"NOT_CONFIGURED",partnerId,status:"ACTIVE",approvedAt:new Date(),approvedBy:reviewerId},{transaction:t});await db.BusinessCreditAccount.create({id:crypto.randomUUID(),business_profile_id:profile.business_customer_id,currency:"LKR",credit_limit:0,status:"DISABLED"},{transaction:t});await user.update({accountType:"business_customer",tokenVersion:user.tokenVersion+1},{transaction:t});await revokeAllUserSessions(user.id,"BUSINESS_APPLICATION_APPROVED",t);await application.update({status:"APPROVED",reviewed_by:reviewerId,reviewed_at:new Date(),rejection_reason:null},{transaction:t});return {application,profile};});
const reject=async(id,reviewerId,reason)=>db.sequelize.transaction(async t=>{const application=await db.BusinessApplication.findByPk(id,{transaction:t,lock:t.LOCK.UPDATE});if(!application)throw Object.assign(new Error("Application not found"),{status:404,code:"NOT_FOUND"});if(!["PENDING","UNDER_REVIEW"].includes(application.status))throw Object.assign(new Error("Application cannot be rejected from its current state"),{status:409,code:"INVALID_TRANSITION"});await application.update({status:"REJECTED",reviewed_by:reviewerId,reviewed_at:new Date(),rejection_reason:reason},{transaction:t});return application;});
module.exports={apply,approve,reject,normalize};
+1
View File
@@ -4,6 +4,7 @@ const db = require("../../models");
const templates = { const templates = {
otp: { subject: "Your ZUMRI login code" }, passwordChanged: { subject: "Your ZUMRI password was changed" }, otp: { subject: "Your ZUMRI login code" }, passwordChanged: { subject: "Your ZUMRI password was changed" },
passwordReset: { subject: "Reset your ZUMRI password" }, emailVerification: { subject: "Verify your ZUMRI email" }, welcome: { subject: "Welcome to ZUMRI" }, passwordReset: { subject: "Reset your ZUMRI password" }, emailVerification: { subject: "Verify your ZUMRI email" }, welcome: { subject: "Welcome to ZUMRI" },
businessApplicationReceived: { subject: "Your ZUMRI business application was received" }, businessApplicationApproved: { subject: "Your ZUMRI business application was approved" }, businessApplicationRejected: { subject: "Your ZUMRI business application was reviewed" }, businessAccountStatusChanged: { subject: "Your ZUMRI business account status changed" },
}; };
const send = async ({ templateKey, recipient, locale = "en", variables = {}, correlationId, notificationId, userId, eventId }) => { const send = async ({ templateKey, recipient, locale = "en", variables = {}, correlationId, notificationId, userId, eventId }) => {
if (!templates[templateKey]) throw new Error("Unknown email template"); if (!templates[templateKey]) throw new Error("Unknown email template");
@@ -0,0 +1,2 @@
const crypto=require("crypto"); const db=require("../../models"); const email=require("../email/email.service");
const publish=async({type,user,headline,description,templateKey,variables={},correlationId})=>{const notification=await db.notification.create({notification_id:`notif_${crypto.randomUUID()}`,notificationHeadline:headline,notificationDescription:description,notificationType:"USER",dateCreated:new Date()});await db.userNotification.create({user_id:user.id,notification_id:notification.notification_id});if(templateKey)await email.send({templateKey,recipient:user.email,userId:user.id,notificationId:notification.notification_id,variables,correlationId,eventId:`${type}-${notification.notification_id}`});return notification;};module.exports={publish};
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>Your business account status is now {{status}}.</p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>Your business application for {{businessName}} was approved. Partner ID: {{partnerId}}.</p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>We received your business application for {{businessName}}. Reference: {{applicationId}}.</p></body></html>
@@ -0,0 +1 @@
<!doctype html><html><body><p>Hello {{firstName}},</p><p>Your business application for {{businessName}} was not approved. Reason: {{reason}}.</p></body></html>
+3
View File
@@ -0,0 +1,3 @@
const {z}=require("zod"); const request=(body)=>z.object({body:z.object(body).strict(),params:z.object({}).passthrough(),query:z.object({}).passthrough()}); const text=(n)=>z.string().trim().min(1).max(n).transform(v=>v.replace(/\s+/g," "));
const details={businessName:text(180),legalName:text(180),registrationNumber:text(100),taxNumber:text(100).optional().nullable(),businessType:text(80),contactEmail:z.string().trim().toLowerCase().email(),contactPhone:z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/),website:z.string().trim().url().optional().nullable()};
module.exports={application:request(details),reject:request({reason:text(500)}),businessUpdate:request({businessName:text(180).optional(),legalName:text(180).optional(),taxNumber:text(100).optional().nullable(),businessType:text(80).optional(),contactEmail:z.string().trim().toLowerCase().email().optional(),contactPhone:z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/).optional(),website:z.string().trim().url().optional().nullable()}),credit:request({creditLimit:z.coerce.number().min(0).max(9999999999999),currency:z.string().trim().length(3).transform(v=>v.toUpperCase()),status:z.enum(["DISABLED","ACTIVE","SUSPENDED"])}),settlement:request({settlementTermId:z.string().min(1)}),status:request({status:z.enum(["ACTIVE","SUSPENDED","CLOSED"])}),contact:request({name:text(150),title:text(100).optional().nullable(),email:z.string().trim().email().optional().nullable(),phone:z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/).optional().nullable(),isPrimary:z.boolean().optional()})};
+9
View File
@@ -0,0 +1,9 @@
const { z } = require("zod");
const request = (body) => z.object({ body: z.object(body).strict(), params: z.object({}).passthrough(), query: z.object({}).passthrough() });
const text = (max) => z.string().trim().min(1).max(max).transform((v) => v.replace(/\s+/g, " "));
const addressFields = { label: text(50), recipientName: text(150), phoneNumber: z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/), addressLine1: text(255), addressLine2: text(255).optional().nullable(), city: text(100), district: text(100).optional().nullable(), province: text(100).optional().nullable(), postalCode: z.string().trim().max(20).regex(/^[A-Za-z0-9 -]*$/).optional().nullable(), countryCode: z.string().trim().length(2).transform(v => v.toUpperCase()), isDefaultShipping: z.boolean().optional(), isDefaultBilling: z.boolean().optional() };
module.exports = {
profileUpdate: request({ firstName: text(100).optional(), lastName: text(100).optional(), phoneNumber: z.string().trim().regex(/^\+?[0-9 ()-]{7,30}$/).optional().nullable(), dateOfBirth: z.coerce.date().max(new Date()).optional().nullable(), preferredLanguage: z.enum(["en", "si", "ta"]).optional(), theme: z.enum(["light", "dark", "system"]).optional(), marketingEmailEnabled: z.boolean().optional(), marketingPushEnabled: z.boolean().optional(), inAppNotificationsEnabled: z.boolean().optional(), avatarUploadId: z.coerce.number().int().positive().optional().nullable(), backgroundUploadId: z.coerce.number().int().positive().optional().nullable() }),
addressCreate: request(addressFields), addressUpdate: request(Object.fromEntries(Object.entries(addressFields).map(([k,v]) => [k,v.optional()]))),
deactivate: request({ confirmation: z.literal("DEACTIVATE"), password: z.string().min(1).optional() }),
};
@@ -0,0 +1,14 @@
"use strict";
module.exports={async up(q,S){await q.sequelize.transaction(async transaction=>{
for(const [name,def] of [["preferred_language",{type:S.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"}],["marketing_email_enabled",{type:S.BOOLEAN,allowNull:false,defaultValue:true}],["marketing_push_enabled",{type:S.BOOLEAN,allowNull:false,defaultValue:false}],["in_app_notifications_enabled",{type:S.BOOLEAN,allowNull:false,defaultValue:true}]])await q.addColumn("profiles",name,def,{transaction});
await q.createTable("addresses",{id:{type:S.STRING,primaryKey:true},user_id:{type:S.STRING,allowNull:true,references:{model:"users",key:"id"},onDelete:"CASCADE"},business_profile_id:{type:S.STRING,allowNull:true},label:{type:S.STRING(50),allowNull:false},recipient_name:{type:S.STRING(150),allowNull:false},phone_number:{type:S.STRING(30),allowNull:false},address_line_1:{type:S.STRING(255),allowNull:false},address_line_2:S.STRING(255),city:{type:S.STRING(100),allowNull:false},district:S.STRING(100),province:S.STRING(100),postal_code:S.STRING(20),country_code:{type:S.STRING(2),allowNull:false},address_type:{type:S.ENUM("CUSTOMER","REGISTERED","BILLING","DELIVERY"),allowNull:false,defaultValue:"CUSTOMER"},is_default_shipping:{type:S.BOOLEAN,allowNull:false,defaultValue:false},is_default_billing:{type:S.BOOLEAN,allowNull:false,defaultValue:false},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("addresses",["user_id"],{name:"addresses_user_idx",transaction});await q.addIndex("addresses",["business_profile_id"],{name:"addresses_business_idx",transaction});
await q.createTable("business_applications",{id:{type:S.STRING,primaryKey:true},user_id:{type:S.STRING,allowNull:false,references:{model:"users",key:"id"}},business_name:{type:S.STRING(180),allowNull:false},legal_name:{type:S.STRING(180),allowNull:false},registration_number:{type:S.STRING(100),allowNull:false},tax_number:S.STRING(100),business_type:{type:S.STRING(80),allowNull:false},contact_email:{type:S.STRING,allowNull:false},contact_phone:{type:S.STRING(30),allowNull:false},website:S.STRING,status:{type:S.ENUM("PENDING","UNDER_REVIEW","APPROVED","REJECTED","CANCELLED"),allowNull:false,defaultValue:"PENDING"},reviewed_by:S.STRING,reviewed_at:S.DATE,rejection_reason:S.STRING(500),createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("business_applications",["user_id","status"],{name:"business_applicant_status_idx",transaction});await q.addIndex("business_applications",["status","createdAt"],{name:"business_application_review_idx",transaction});
for(const [name,def] of [["legalName",{type:S.STRING}],["taxNumber",{type:S.STRING}],["website",{type:S.STRING}],["partnerId",{type:S.STRING,allowNull:true}],["status",{type:S.ENUM("ACTIVE","SUSPENDED","CLOSED"),allowNull:false,defaultValue:"ACTIVE"}],["approvedAt",{type:S.DATE,allowNull:true}],["approvedBy",{type:S.STRING,allowNull:true}],["settlement_term_id",{type:S.STRING,allowNull:true}]])await q.addColumn("business_customers",name,def,{transaction});await q.addIndex("business_customers",["partnerId"],{unique:true,name:"business_partner_unique",transaction});await q.addIndex("business_customers",["businessRegistrationNumber"],{unique:true,name:"business_registration_unique",transaction});await q.addIndex("business_customers",["status"],{name:"business_status_idx",transaction});
await q.createTable("business_contacts",{id:{type:S.STRING,primaryKey:true},business_profile_id:{type:S.STRING,allowNull:false,references:{model:"business_customers",key:"business_customer_id"},onDelete:"CASCADE"},name:{type:S.STRING(150),allowNull:false},title:S.STRING(100),email:S.STRING,phone:S.STRING(30),is_primary:{type:S.BOOLEAN,allowNull:false,defaultValue:false},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("business_contacts",["business_profile_id"],{name:"business_contacts_profile_idx",transaction});
await q.createTable("business_credit_accounts",{id:{type:S.STRING,primaryKey:true},business_profile_id:{type:S.STRING,allowNull:false,references:{model:"business_customers",key:"business_customer_id"},onDelete:"CASCADE"},currency:{type:S.STRING(3),allowNull:false,defaultValue:"LKR"},credit_limit:{type:S.DECIMAL(15,2),allowNull:false,defaultValue:0},status:{type:S.ENUM("DISABLED","ACTIVE","SUSPENDED"),allowNull:false,defaultValue:"DISABLED"},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});await q.addIndex("business_credit_accounts",["business_profile_id"],{unique:true,name:"business_credit_profile_unique",transaction});
await q.createTable("settlement_terms",{id:{type:S.STRING,primaryKey:true},code:{type:S.STRING(30),allowNull:false,unique:true},name:{type:S.STRING(100),allowNull:false},days:{type:S.INTEGER,allowNull:false},is_active:{type:S.BOOLEAN,allowNull:false,defaultValue:true},createdAt:{type:S.DATE,allowNull:false},updatedAt:{type:S.DATE,allowNull:false}},{transaction});
const now=new Date();await q.bulkInsert("settlement_terms",[{id:"term_prepaid",code:"PREPAID",name:"Prepaid",days:0,is_active:true,createdAt:now,updatedAt:now},{id:"term_net_7",code:"NET_7",name:"Net 7",days:7,is_active:true,createdAt:now,updatedAt:now},{id:"term_net_14",code:"NET_14",name:"Net 14",days:14,is_active:true,createdAt:now,updatedAt:now},{id:"term_net_30",code:"NET_30",name:"Net 30",days:30,is_active:true,createdAt:now,updatedAt:now}],{transaction});
await q.addConstraint("addresses",{fields:["business_profile_id"],type:"foreign key",name:"addresses_business_fk",references:{table:"business_customers",field:"business_customer_id"},onDelete:"CASCADE",transaction});
await q.sequelize.query("ALTER TABLE addresses ADD CONSTRAINT addresses_exactly_one_owner CHECK ((user_id IS NOT NULL) + (business_profile_id IS NOT NULL) = 1)",{transaction});
await q.addConstraint("business_customers",{fields:["settlement_term_id"],type:"foreign key",name:"business_settlement_term_fk",references:{table:"settlement_terms",field:"id"},onDelete:"SET NULL",transaction});
});},async down(){throw new Error("Phase 3 migration is forward-only; restore from backup for rollback");}};
+11
View File
@@ -0,0 +1,11 @@
describe("business application service", () => {
beforeEach(() => jest.resetModules());
const setup = ({ active = null } = {}) => {
const created={id:"app-1",user_id:"usr-1",status:"PENDING"};
const models={sequelize:{transaction:jest.fn(async cb=>cb({LOCK:{UPDATE:"UPDATE"}}))},User:{findByPk:jest.fn().mockResolvedValue({id:"usr-1",accountStatus:"ACTIVE",emailVerifiedAt:new Date(),accountType:"customer"})},BusinessApplication:{findOne:jest.fn().mockResolvedValue(active),create:jest.fn().mockResolvedValue(created)}};
jest.doMock("../../app/models",()=>models);jest.doMock("../../app/utils/referenceNumber.util",()=>({nextSequence:jest.fn()}));jest.doMock("../../app/services/auth/session.service",()=>({revokeAllUserSessions:jest.fn()}));
return {service:require("../../app/services/business/business.service"),models,created};
};
test("verified active customer can submit an application",async()=>{const {service,models,created}=setup();const result=await service.apply("usr-1",{businessName:"Acme",legalName:"Acme Ltd",registrationNumber:"PV1",businessType:"Retail",contactEmail:"a@example.com",contactPhone:"+94770000000"});expect(result).toBe(created);expect(models.BusinessApplication.create).toHaveBeenCalledWith(expect.objectContaining({user_id:"usr-1",status:"PENDING"}),expect.anything());});
test("duplicate active application is prevented",async()=>{const {service}=setup({active:{id:"existing"}});await expect(service.apply("usr-1",{})).rejects.toMatchObject({code:"ACTIVE_APPLICATION_EXISTS",status:409});});
});
@@ -0,0 +1,42 @@
const customer = require("../../app/validation/customer.schemas");
const business = require("../../app/validation/business.schemas");
const wrap = (body) => ({ body, params: {}, query: {} });
describe("Phase 3 strict customer and business inputs", () => {
test("accepts supported locales and rejects unsupported locales", () => {
expect(customer.profileUpdate.safeParse(wrap({ preferredLanguage: "si" })).success).toBe(true);
expect(customer.profileUpdate.safeParse(wrap({ preferredLanguage: "fr" })).success).toBe(false);
});
test.each(["accountType", "accountStatus", "roles", "permissions", "tokenVersion", "creditLimit", "partnerId"])("profile rejects %s mass assignment", (field) => {
expect(customer.profileUpdate.safeParse(wrap({ [field]: "unsafe" })).success).toBe(false);
});
test("validates and normalizes an international address", () => {
const result = customer.addressCreate.safeParse(wrap({ label:" Home ",recipientName:" Asha Perera ",phoneNumber:"+94 77 000 0000",addressLine1:" 1 Main Road ",city:"Colombo",countryCode:"lk" }));
expect(result.success).toBe(true); expect(result.data.body.countryCode).toBe("LK"); expect(result.data.body.recipientName).toBe("Asha Perera");
});
test("address rejects arbitrary fields", () => {
expect(customer.addressCreate.safeParse(wrap({ label:"Home",recipientName:"Asha",phoneNumber:"+94770000000",addressLine1:"Road",city:"Colombo",countryCode:"LK",userId:"other" })).success).toBe(false);
});
test("deactivation requires explicit confirmation", () => {
expect(customer.deactivate.safeParse(wrap({ confirmation:"DEACTIVATE" })).success).toBe(true);
expect(customer.deactivate.safeParse(wrap({ confirmation:"yes" })).success).toBe(false);
});
test("business applications reject approval and identity fields", () => {
const base={businessName:"Acme",legalName:"Acme Ltd",registrationNumber:"PV-1",businessType:"Retail",contactEmail:"owner@example.com",contactPhone:"+94770000000"};
expect(business.application.safeParse(wrap(base)).success).toBe(true);
expect(business.application.safeParse(wrap({...base,status:"APPROVED",accountType:"business_customer"})).success).toBe(false);
});
test("business self-update rejects partner, approval, and credit fields", () => {
expect(business.businessUpdate.safeParse(wrap({ businessName:"New Name" })).success).toBe(true);
expect(business.businessUpdate.safeParse(wrap({ partnerId:"ZUM-BIZ-9" })).success).toBe(false);
expect(business.businessUpdate.safeParse(wrap({ creditLimit:5000 })).success).toBe(false);
});
test("credit configuration rejects negative values and normalizes currency", () => {
expect(business.credit.safeParse(wrap({creditLimit:-1,currency:"lkr",status:"ACTIVE"})).success).toBe(false);
const result=business.credit.safeParse(wrap({creditLimit:1000.25,currency:"lkr",status:"ACTIVE"}));expect(result.success).toBe(true);expect(result.data.body.currency).toBe("LKR");
});
test("rejection requires a reason", () => {
expect(business.reject.safeParse(wrap({reason:"Insufficient registration evidence"})).success).toBe(true);
expect(business.reject.safeParse(wrap({})).success).toBe(false);
});
});