Auth #1
@@ -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.
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
|
||||
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.
|
||||
@@ -17,4 +17,7 @@ module.exports = {
|
||||
DOCUMENTS_READ: "documents.read", DOCUMENTS_CREATE: "documents.create", DOCUMENTS_DELETE: "documents.delete",
|
||||
NOTIFICATIONS_MANAGE: "notifications.manage", NOTIFICATIONS_READ: "notifications.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",
|
||||
};
|
||||
|
||||
@@ -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);}};
|
||||
@@ -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,7 +2,8 @@ const db = require("../models");
|
||||
const storage = require("../services/storage/storage.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) => {
|
||||
let objectKey;
|
||||
|
||||
@@ -43,6 +43,11 @@ db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes);
|
||||
db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes);
|
||||
db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes);
|
||||
db.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
|
||||
db.Upload = require("./upload/upload.model")(sequelize, DataTypes);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -21,7 +21,7 @@ module.exports = (sequelize, DataTypes) => {
|
||||
},
|
||||
businessRegistrationNumber: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
allowNull: false, unique: true,
|
||||
},
|
||||
businessType: {
|
||||
type: DataTypes.STRING,
|
||||
@@ -47,6 +47,14 @@ module.exports = (sequelize, DataTypes) => {
|
||||
type: DataTypes.STRING,
|
||||
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",
|
||||
@@ -59,6 +67,10 @@ module.exports = (sequelize, DataTypes) => {
|
||||
foreignKey: "user_id",
|
||||
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;
|
||||
|
||||
@@ -47,7 +47,11 @@ module.exports = (sequelize, DataTypes) => {
|
||||
phone_number:{
|
||||
type: DataTypes.STRING,
|
||||
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",
|
||||
|
||||
@@ -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 });
|
||||
@@ -88,6 +88,8 @@ module.exports = (sequelize, DataTypes) => {
|
||||
User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" });
|
||||
User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" });
|
||||
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;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
const { Queue } = require("bullmq");
|
||||
const connection = require("../config/redisClient");
|
||||
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 } } });
|
||||
const options = { attempts: 5, backoff: { type: "exponential", delay: 2000 }, removeOnComplete: { age: 3600, count: 1000 }, removeOnFail: { age: 604800, count: 5000 } };
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -23,6 +23,9 @@ const notificationRoutes = require("./notification.routes");
|
||||
const adminAuthRoutes = require("./adminAuth.routes");
|
||||
const riderAuthRoutes = require("./riderAuth.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();
|
||||
|
||||
@@ -38,5 +41,8 @@ router.use("/permissions", permissionRoutes);
|
||||
router.use("/admin/auth", adminAuthRoutes);
|
||||
router.use("/rider/auth", riderAuthRoutes);
|
||||
router.use("/admin/users", adminUserRoutes);
|
||||
router.use("/addresses", addressRoutes);
|
||||
router.use("/business", businessRoutes);
|
||||
router.use("/admin/business", adminBusinessRoutes);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -24,6 +24,11 @@ const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
|
||||
const validate = require("../middleware/validate.middleware");
|
||||
const schemas = require("../validation/auth.schemas");
|
||||
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);
|
||||
|
||||
|
||||
@@ -18,9 +18,13 @@ const {
|
||||
|
||||
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
|
||||
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.patch("/me", authenticate, userController.updateCurrentUser);
|
||||
router.post("/me/deactivate", authenticate, validate(customerSchemas.deactivate), customerProfile.deactivate);
|
||||
|
||||
|
||||
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};
|
||||
@@ -4,6 +4,7 @@ const db = require("../../models");
|
||||
const templates = {
|
||||
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" },
|
||||
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 }) => {
|
||||
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>
|
||||
@@ -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()})};
|
||||
@@ -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");}};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user