Compare commits
5 Commits
aeb3ab7fcf
...
835f30b4fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 835f30b4fb | |||
| f920ca8920 | |||
| abb760cc19 | |||
| 222483d194 | |||
| cd2c1c6d08 |
@@ -75,3 +75,6 @@ EMAIL_VERIFICATION_TTL_SECONDS=86400
|
||||
PUPPETEER_EXECUTABLE_PATH=
|
||||
GOOGLE_CLIENT_ID=
|
||||
APPLE_CLIENT_ID=
|
||||
# Phase 10 bounded reconciliation and privacy retention
|
||||
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE=100
|
||||
RECOMMENDATION_EVENT_RETENTION_DAYS=90
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Delivery and Rider API
|
||||
|
||||
All endpoints use `/api/v1`. There is no public tracking endpoint.
|
||||
|
||||
## Admin fulfillment
|
||||
|
||||
- `GET /admin/shipments`, `GET /admin/shipments/:id`
|
||||
- `POST /admin/shipments` creates a delivery from immutable Order address/shipping snapshots and item quantities.
|
||||
- `POST /admin/shipments/return-pickup` creates one pickup for an approved return.
|
||||
- `POST /admin/shipments/:id/assign`, `/unassign`, `/reschedule`, `/cancel`
|
||||
- `GET /admin/dispatch` projects unassigned, active, and attention-needed shipments.
|
||||
|
||||
Administration requires explicit shipment, dispatch, rider, or return-logistics permission. Shipment creation supports partial quantities, locks the order, checks existing non-cancelled allocations, and uses `Idempotency-Key` event identity.
|
||||
|
||||
## Rider administration
|
||||
|
||||
- `GET /admin/riders`, `GET /admin/riders/:id`
|
||||
- `POST /admin/riders` attaches an operational profile to an existing RIDER User; it does not create another identity/password system.
|
||||
- `PATCH /admin/riders/:id`
|
||||
- `GET /admin/riders/:id/assignments`
|
||||
|
||||
Profile status is independent of User account status. Availability is AVAILABLE, BUSY, or OFFLINE and capacity is configurable.
|
||||
|
||||
## Rider workflow
|
||||
|
||||
- `GET /rider/shipments`, `GET /rider/shipments/:id`
|
||||
- Explicit actions: accept, reject, pickup, in-transit, out-for-delivery, deliver, and fail-delivery.
|
||||
- `PUT /rider/location` stores optional latest coordinates; location is not included in customer tracking.
|
||||
|
||||
Every operation derives the rider from authentication and constrains the current assignment. State changes use row locks and unique event IDs. Delivery requires structured proof; linked photo/signature uploads must be AVAILABLE images uploaded by that rider.
|
||||
|
||||
## Customer tracking
|
||||
|
||||
- `GET /orders/:orderId/tracking`
|
||||
- `GET /returns/:id/tracking`
|
||||
|
||||
Responses contain shipment status and ordered event projections, not rider phone, location, capacity, assignment history, payment, or provider data.
|
||||
|
||||
## Behavior boundaries
|
||||
|
||||
Delivered item quantities recalculate Order fulfillment without changing payment status. Failed delivery never refunds or restocks. Return-pickup delivery marks the RMA received for Phase 7 inspection; it does not refund or restock. External couriers, maps, route optimization, delivery OTP, live sockets, and AI dispatch are not implemented.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Loyalty and Rewards API
|
||||
|
||||
All customer APIs are authenticated and derive the loyalty owner from the session.
|
||||
|
||||
## Customer
|
||||
|
||||
- `GET /api/v1/loyalty` — balance, debt, lifetime points, current tier, benefits, and next-tier progress.
|
||||
- `GET /api/v1/loyalty/history?page=&limit=` — paginated immutable ledger.
|
||||
- `GET /api/v1/loyalty/tiers`, `GET /api/v1/loyalty/rewards`.
|
||||
- `POST /api/v1/loyalty/redeem` — requires `Idempotency-Key` and reward ID.
|
||||
- `GET /api/v1/loyalty/vouchers` — owner-specific coupon entitlements.
|
||||
- `GET /api/v1/loyalty/referral`, `POST /api/v1/loyalty/referral/claim`.
|
||||
|
||||
## Administration
|
||||
|
||||
- Loyalty accounts and referral listing.
|
||||
- Tier, earn-rule, and reward listing/creation.
|
||||
- `POST /admin/loyalty/adjustments` requires `loyalty.points.adjust`, a non-zero integer delta, reason, and idempotency key.
|
||||
|
||||
## Policy
|
||||
|
||||
Purchase points are awarded only after authoritative PAID processing. Eligible value is discounted merchandise (`subtotal - discountTotal`), excluding shipping, tax, and duties. Money is divided by configured `amountUnit`, floored to whole units, then multiplied by configured points.
|
||||
|
||||
Verified-review rewards require APPROVED and verified purchase. Referral rewards require the referred account's qualifying paid Order. Birthday events use `BIRTHDAY:user:year`; February 29 follows the actual calendar date.
|
||||
|
||||
Ledger entries are never edited. Refund reversals append negative entries. If already-spent points prevent a complete debit, available points floor at zero and the remainder becomes explicit `pointsDebt`; later earnings repay debt first.
|
||||
|
||||
Redemption locks the account/reward, validates limits, spends earliest-expiring allocations first, and creates at most one result per account/idempotency key. Coupon rewards reuse Phase 5 Coupon and issue an owner-specific entitlement.
|
||||
|
||||
Expiry is configured per earn rule and reconciled in bounded daily batches. No direct balance/tier mutation API exists.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Newsletter Consent API
|
||||
|
||||
`POST /api/v1/newsletter/subscribe` accepts `email`, `locale`, and an allowlisted source (`HOME_FOOTER`, `CHECKOUT`, or `ACCOUNT`). Email is trimmed, lowercased, and uniquely stored. Repeated subscription is idempotent and enumeration-safe.
|
||||
|
||||
The current product policy uses immediate single opt-in. Each subscription receives a cryptographically random unsubscribe token, while only its SHA-256 hash is stored. `POST /api/v1/newsletter/unsubscribe/:token` always returns a neutral success response. Resubscription records fresh consent and rotates the token.
|
||||
|
||||
Authenticated users may inspect their linked consent at `GET /api/v1/newsletter/me`. Admin listing is `GET /api/v1/admin/newsletter/subscribers` and requires `newsletter.subscribers.read`; token hashes are never returned. Export/manage permissions are reserved, and Phase 10 does not implement campaign sending.
|
||||
|
||||
Newsletter consent is legally distinct from Phase 3 profile marketing preferences. An email preference does not create newsletter consent, and an explicit newsletter unsubscribe takes precedence until a new explicit subscribe action occurs.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Orders and Payments API
|
||||
|
||||
All `/api/v1` customer endpoints derive ownership from authentication.
|
||||
|
||||
## Orders
|
||||
|
||||
- `POST /orders/from-checkout` converts an owned READY checkout. Conversion locks the checkout, verifies ACTIVE reservations, copies immutable snapshots, converts the cart, and is idempotent by unique checkout ID.
|
||||
- `GET /orders` and `GET /orders/:id` provide owner-scoped history/detail.
|
||||
- `POST /orders/:id/cancel` releases unpaid reservations. Paid orders require an explicit refund.
|
||||
- Admin: `GET /admin/orders`, `GET /admin/orders/:id`, cancel, and mark-processing action endpoints.
|
||||
|
||||
Legal transitions are centralized. Payment, order, and fulfillment status are separate.
|
||||
|
||||
## Payments and webhooks
|
||||
|
||||
- `POST /orders/:id/payment`, `GET /orders/:id/payment`
|
||||
- `POST /payments/webhooks/payhere` is unauthenticated at the session layer but requires the PayHere merchant hash.
|
||||
|
||||
Online browser redirects are never authoritative. A verified webhook must match local payment reference, currency, and DECIMAL amount. Unique provider event IDs make retries idempotent. A first successful event consumes each reservation once, records coupon redemption, marks payment/order paid, and issues invoice metadata. Failure does not consume inventory.
|
||||
|
||||
PayHere configuration uses `PAYHERE_MERCHANT_ID`, `PAYHERE_MERCHANT_SECRET`, and notify/return/cancel URLs. Stripe has an explicit disabled adapter until its official SDK and webhook secret are configured. No raw card or provider secret is accepted or returned.
|
||||
|
||||
## Invoices
|
||||
|
||||
- `GET /orders/:id/invoice` is owner-scoped.
|
||||
|
||||
Invoice numbers use the locked ReferenceNumber sequence. Invoice metadata is created once per paid order. PDF generation is reserved for the existing document worker integration; no second PDF/storage subsystem was introduced.
|
||||
|
||||
## Refunds
|
||||
|
||||
- `POST /admin/orders/:id/refunds` requires `payments.refund` and `Idempotency-Key`.
|
||||
|
||||
Requested item quantities and captured totals are locked and validated. A refund never restocks inventory automatically. Provider refund execution remains disabled until provider API credentials/workflows are validated.
|
||||
|
||||
## Returns
|
||||
|
||||
- Customer: `POST /orders/:orderId/returns`, `GET /returns`, `GET /returns/:id`.
|
||||
- Admin: list, approve, reject, mark-received, and complete actions.
|
||||
|
||||
The return window uses `RETURN_WINDOW_DAYS` (default 30). Quantity cannot exceed the remaining purchased quantity. Only accepted RESTOCKABLE items are added through the inventory service; damaged/non-restockable items are not. EXCHANGE records intent only.
|
||||
|
||||
Business credit purchasing is intentionally disabled: no locked credit ledger was added without an approved accounting policy. Delivery, loyalty, support AI, and analytics are outside Phase 7.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Deterministic Recommendation API
|
||||
|
||||
Phase 10 recommendations are local, explainable rules—not AI or machine learning.
|
||||
|
||||
- `POST /api/v1/recommendations/events` accepts authenticated, idempotent `PRODUCT_VIEW` events only. Clients cannot claim purchases or other authoritative commerce events.
|
||||
- `GET /api/v1/recommendations/recently-viewed` returns a customer's unique recent public products.
|
||||
- `GET /api/v1/products/:productId/recommendations/related` prefers Phase 4 `ProductRelation`, then bounded same-category/brand fallback.
|
||||
- `GET /api/v1/recommendations/trending` ranks a 30-day bounded aggregate with purchase/cart/wishlist/click/view weights.
|
||||
- `GET /api/v1/recommendations/popular` uses eligible paid order item quantities net of refunded quantity, with a featured fallback.
|
||||
- `GET /api/v1/recommendations/for-you` combines the authenticated user's recent product interests with aggregate candidates and falls back to trending/featured products.
|
||||
|
||||
Responses reuse the Phase 4 localized product summary and signed media projection. Only active/public products with an active variant are eligible. Exact stock is not exposed. Limits are capped at 24. Business-specific price quotation remains a known integration item; no customer-specific recommendation response is shared in cache.
|
||||
|
||||
Events store no email, IP, access token, cookie, raw session key, or full user agent. Session keys, if enabled later for anonymous ingestion, are hash-only. Retention defaults to 90 days and cleanup is bounded. The clean service boundary can later be replaced by a separately authenticated recommendation service; Phase 10 adds no URL, credential, bypass, LLM, embedding, vector store, or ML model.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Support and Help API
|
||||
|
||||
Phase 10 adds a permission-gated support case system and a localized help center. All routes are under `/api/v1`.
|
||||
|
||||
## Customer support
|
||||
|
||||
- `POST /support/tickets` creates a self-owned ticket and initial public message in one transaction. Identity, business context, priority and status are server-derived.
|
||||
- `GET /support/tickets` and `GET /support/tickets/:id` are self-only; internal notes and internal attachments are excluded.
|
||||
- `POST /support/tickets/:id/messages` appends a public reply. A resolved ticket is explicitly reopened; closed/cancelled tickets reject replies.
|
||||
- `POST /support/tickets/:id/close` performs a validated state transition.
|
||||
- `GET /support/tickets/:id/attachments/:attachmentId` authorizes the ticket and visibility before issuing a short-lived signed S3 URL.
|
||||
|
||||
Ticket states are `OPEN`, `ASSIGNED`, `WAITING_FOR_CUSTOMER`, `WAITING_FOR_SUPPORT`, `RESOLVED`, `CLOSED`, and `CANCELLED`. Customers cannot select priority; new tickets default to `NORMAL`. Subjects are limited to 200 characters, messages to 10,000 characters, and five attachments per message. Attachments reuse Phase 2 uploads and allow JPEG, PNG, WebP, or PDF only.
|
||||
|
||||
Related resources support orders, payments, refunds, returns, shipments, loyalty redemptions, and business settlements. Creation verifies the resource against the authenticated customer or business; a resource identifier alone never grants access.
|
||||
|
||||
## Staff support
|
||||
|
||||
Under `/admin/support/tickets`, staff can list/detail, assign or atomically claim, reply, add internal notes, change priority, resolve, reopen, close, and download attachments. Permissions are granular: `support.tickets.read`, `.assign`, `.reply`, `.status`, `.priority`, `.internal_notes`, and `.escalate`. Category and SLA controls use `support.categories.manage` and `support.sla.manage`.
|
||||
|
||||
State/assignment operations lock the ticket row. Events are append-only. SLA deadlines are snapshotted from the active priority policy at creation using clock time; a bounded five-minute reconciliation creates idempotent first-response or resolution escalations. Business-hour calendars and multi-instance cron validation remain Phase 11 work.
|
||||
|
||||
## Help center
|
||||
|
||||
Public endpoints are `GET /help/categories`, `/help/categories/:slug`, `/help/articles`, `/help/articles/:slug`, and `/help/search?q=`. Only active categories and published articles are returned. `en`, `si`, and `ta` use the Phase 4 locale resolver with English fallback. Search is bounded to 2–100 query characters and at most 50 results.
|
||||
|
||||
Admin endpoints under `/admin/help` list/create categories and articles and explicitly publish/archive articles. They require `help.read`, `help.manage`, or `help.publish`. Article bodies are stored as plain Markdown-like text with HTML tags removed; consumers must render text/Markdown safely and must not treat it as trusted HTML.
|
||||
|
||||
All collection APIs use bounded pagination or bounded results and the standard `{ success, data, pagination? }` envelope.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Wholesale Completion API
|
||||
|
||||
Business endpoints require an authenticated, ACTIVE `business_customer`; organization identity is never accepted from the request.
|
||||
|
||||
## Business account
|
||||
|
||||
- `GET /api/v1/business/dashboard`
|
||||
- `GET /api/v1/business/credit`
|
||||
- `POST /api/v1/business/orders/:id/use-credit` with `Idempotency-Key`
|
||||
- `GET /api/v1/business/settlements`, `GET /api/v1/business/settlements/:id`
|
||||
- `GET /api/v1/business/orders/recent`
|
||||
|
||||
The dashboard uses paid Order snapshots for monthly/lifetime volume and discounts, existing BusinessTier for classification, the immutable credit ledger for utilization, and settlement records for next payment information.
|
||||
|
||||
## Administration
|
||||
|
||||
- `POST /admin/wholesale/credit/transactions` (`wholesale.credit.manage`)
|
||||
- `GET /admin/wholesale/businesses/:businessId/credit` (`wholesale.credit.read`)
|
||||
- Settlement list/generation/issue/mark-paid endpoints with settlement permissions.
|
||||
|
||||
Credit formula: `available = creditLimit - ledger balance`. Captures/authorizations increase utilization; payment, release, and refund entries decrease it. Every operation locks the existing BusinessCreditAccount, validates ACTIVE business/account status and currency, and uses a unique event ID. Credit-backed Order placement atomically captures credit, consumes reservations, records an INTERNAL_CREDIT Payment, marks the Order paid, and reuses invoice issuance.
|
||||
|
||||
Settlements snapshot ledger activity for a unique business/period. Due dates come from the existing SettlementTerm. Numbers use ReferenceNumber, and only explicit lifecycle actions are accepted.
|
||||
|
||||
Payment allocation storage exists as a foundation. External bank matching, general ledger, ERP, tax-authority integration, and document-worker validation remain outside this phase.
|
||||
@@ -417,3 +417,30 @@ Date: 2026-09-03. Inventory/reservation foundations, business pricing, promotion
|
||||
## Phase 6 Completion Update
|
||||
|
||||
Date: 2026-09-03. Module 06 is 91%, Module 08 is 84%, and Module 07 is revised to 86%. Authenticated cart is 92%, wishlist 92%, shipping 86%, checkout 88%, and reservation integration 90%. Nine models, a forward-only migration, owner-scoped shopping APIs, permission-protected shipping administration, server-authoritative totals, atomic inventory reservation composition, idempotent checkout creation, and bounded expiry/cancellation release are implemented. Verification passes 20 suites/95 tests and 258 JavaScript syntax checks. The migration was not executed. Before Phase 7, staging must precheck legacy shopping/shipping tables and address/currency compatibility, seed shipping configuration/permissions, apply migrations, and validate real multi-connection InnoDB concurrency plus multi-instance expiry behavior.
|
||||
## Phase 7 Completion Update
|
||||
|
||||
Date: 2026-09-09. Module 09 is 86%, Module 10 is 76%, and Module 13 is revised to 82%. Orders are 90%, payments 78%, invoices 72%, refunds 68%, returns 82%, coupon redemption 80%, and verified-purchase reviews 90%. Eleven commerce models, a forward-only migration, transactional checkout conversion, owner/admin APIs, explicit state machines, verified/idempotent webhook processing, payment-time inventory consumption, invoice sequencing, itemized refund validation, RMA handling, and return-only restocking were added. Migration and real provider/MySQL/document-worker validation remain staging requirements. Module 16 AI Customer Support Chatbot is intentionally excluded from this backend and planned as a separate service.
|
||||
## Phase 8 Completion Update
|
||||
|
||||
Date: 2026-09-09. Module 11 is 88%; shipments are 90%, riders 86%, assignment/dispatch 86%, tracking 88%, proof of delivery 82%, and return logistics 84%. Module 09 is revised to 90% through physical fulfillment integration. Six logistics models, a forward-only migration, explicit shipment states/actions, partial fulfillment, locked rider capacity/assignment, append-only events, proof media validation, safe tracking, and approved-return pickup were added. Automated checks pass 25 suites/123 tests with 305 JavaScript files syntax-checked. The migration was not executed. Staging must validate real InnoDB concurrency, multi-instance idempotency, proof storage, notification fan-out, permission seeding, and return handoff before production or Phase 9 rollout. Module 16 AI Customer Support Chatbot remains excluded from this backend and will be developed separately.
|
||||
## Phase 9 Completion Update
|
||||
|
||||
Date: 2026-09-09. Module 12 is 86% and Module 13 is revised to 88%. Loyalty accounts are 92%, points ledger 90%, earning rules 86%, membership 88%, rewards/redemption 86%, referrals 82%, birthday rewards 80%, and expiry 80%. Business tiers are 84%, business credit 84%, settlements 78%, wholesale dashboard 82%, and wholesale analytics 76%. Module 07 is revised to 82% through coupon-entitlement foundations; Modules 09/10 are unchanged except for paid-event loyalty and internal-credit integration. Fourteen models, a forward-only migration, bounded cron reconciliation, new owner/admin APIs, and immutable concurrency-safe ledgers were added. Migration and real MySQL/cron/document/notification validation remain staging requirements. Module 16 AI Customer Support Chatbot remains intentionally excluded and will be developed separately.
|
||||
## Phase 10 Completion Update
|
||||
|
||||
Date: 2026-09-09
|
||||
|
||||
- Module 15 Support Ticket: **84%**
|
||||
- Module 17 Product Recommendations: **78%**
|
||||
- Support tickets 90%; messages 88%; assignment 86%; SLA/escalation 74%; attachments 82%; related-resource integration 78%.
|
||||
- Help center 84%; help localization 86%; help search 76%; newsletter consent 84%.
|
||||
- Recommendation events 80%; related 86%; recently viewed 84%; trending 78%; popular 74%; personalized deterministic 70%.
|
||||
- Module 14 notifications is unchanged: event/template delivery fanout remains outstanding.
|
||||
- Module 03 catalogue relationships are reused without a revised completion claim.
|
||||
- Module 04 localization infrastructure is reused without a revised completion claim.
|
||||
- Verification: **28 suites / 145 tests passing**; syntax passed for **373 JavaScript files**; `npm ls --depth=0` reports no dependency problems.
|
||||
- Migration: `20260909100000-phase-10-support-recommendations.js` created but **not executed**. Phase 0–9 migrations were not changed.
|
||||
- Staging requirements: legacy-data precheck; real MySQL migration/FK/query-plan and row-lock validation; Redis/BullMQ/cron multi-instance validation; S3 signed-download and file-content validation; SMTP notification wiring; business-price projection; authoritative shopping/commerce recommendation events; IDOR/E2E/concurrency tests.
|
||||
- Phase 11 readiness: safe to begin hardening after the Phase 10 migration and infrastructure checks are scheduled; Phase 10 is not a production-readiness claim.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded from this backend. It will be developed as a separate service.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# ZUMRI Phase 10 Support, Help Center, Newsletter and Recommendation Foundations
|
||||
|
||||
## Objective
|
||||
|
||||
Provide practical customer support, localized self-service content, explicit newsletter consent, and deterministic recommendations while preserving Phase 0–9 domain ownership.
|
||||
|
||||
## Existing Components Reused
|
||||
|
||||
Phase 1 identity/RBAC; Phase 2 upload, signed S3 access, notification/audit infrastructure; Phase 4 locale, product relations and product DTO; Phase 5 inventory boundary; Phase 7 order/payment truth; Phase 8 shipment truth; Phase 9 loyalty/business ownership; atomic reference numbers and existing cron lifecycle.
|
||||
|
||||
## Support Architecture
|
||||
|
||||
`SupportTicket` owns case lifecycle, `SupportMessage` conversation, `SupportTicketEvent` append-only history, `SupportTicketLink` polymorphic links, and `SupportAttachment` upload references. Ticket creation is transactional. The status graph is explicit; generic status mutation is absent. Agent claim/assignment locks the row. Internal notes and attachments are filtered from customer reads. Resource links validate domain ownership, and signed downloads require ticket authorization.
|
||||
|
||||
## SLA and Escalation
|
||||
|
||||
An active per-priority `SupportSlaPolicy` snapshots first-response and resolution deadlines using `CLOCK_TIME`. The bounded cron detects overdue open work and uses deterministic event keys to create each `SupportEscalation` once. Full calendars, warning tiers, acknowledgement APIs, and verified multi-instance scheduling remain outstanding.
|
||||
|
||||
## Support Notifications, Permissions and Audit
|
||||
|
||||
Phase 2 notification infrastructure is retained as the delivery boundary; full template/fanout wiring is outstanding. New support/help/newsletter/recommendation permissions are explicit. Support lifecycle history is durable in ticket events, and privileged general audit calls are intentionally limited pending real queue integration testing.
|
||||
|
||||
## Help Center Architecture
|
||||
|
||||
Help categories and articles have `en`/`si`/`ta` translation tables and English fallback. Articles implement draft, publish, and archive lifecycle; public APIs query published content only. FAQ is an article type, avoiding a parallel engine. Search uses bounded MySQL `LIKE` queries. HTML tags are removed and bodies are treated as untrusted Markdown-like text.
|
||||
|
||||
## Newsletter Consent
|
||||
|
||||
Newsletter subscriptions are unique by normalized email, source/locale aware, idempotent, independently revocable, and linked to a user when known. Immediate opt-in is the documented policy. Unsubscribe authorization uses a random token with hash-only persistence. Profile marketing preferences never override explicit unsubscribe.
|
||||
|
||||
## Recommendation Architecture
|
||||
|
||||
Privacy-conscious interaction events retain meaningful signals only. Client ingestion is restricted to product views and protected by authentication, validation, rate limiting, and event-id uniqueness. Explicit product relations lead related results. Recently viewed is de-duplicated; trending uses weighted 30-day SQL aggregation; popular uses paid order items net of refunds; for-you deterministically prioritizes recent interests with a featured fallback. Queries and response sizes are bounded, inactive/hidden products are excluded, and the existing localized serializer is reused.
|
||||
|
||||
## Future External Recommendation Service Boundary
|
||||
|
||||
The current implementation is `LOCAL_DETERMINISTIC`. A future external service may implement the same recommendation input/output contract using scoped machine credentials. There is no insecure internal bypass or placeholder service URL.
|
||||
|
||||
## Explicit AI Exclusion
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded. No OpenAI/LLM integration, prompts, embeddings, RAG, vector database, generated replies, agent, or ML training pipeline was implemented.
|
||||
|
||||
## Security and Database Changes
|
||||
|
||||
Customer ownership is server-derived; strict request schemas reject unknown fields; internal visibility is enforced; uploads and linked resources receive independent authorization; newsletter tokens are hash-only; recommendation telemetry cannot spoof purchases. Migration `20260909100000-phase-10-support-recommendations.js` is forward-only and creates 14 tables with uniqueness and query indexes. Precheck legacy support/FAQ/newsletter/event tables and duplicate normalized email addresses before staging. No backfill is fabricated.
|
||||
|
||||
## Tests and Known Limitations
|
||||
|
||||
Unit coverage exercises strict fields, priority/event spoofing, transition policy, token hashing, source allowlists, body safety, and permissions. Real MySQL FK/migration/concurrency, Redis/BullMQ, S3 signed downloads, SMTP notifications, business-price projection, authoritative event hooks, rich CMS update APIs, and multi-instance cron behavior require Phase 11 staging work.
|
||||
|
||||
## Phase 11 Prerequisites
|
||||
|
||||
Apply migrations only after schema/data prechecks and a verified backup. Seed categories/SLA policies and permissions, validate real infrastructure, add concurrency/IDOR/E2E coverage, wire support notifications and authoritative recommendation signals, and measure aggregate query plans before production readiness assessment.
|
||||
@@ -0,0 +1,84 @@
|
||||
# ZUMRI Phase 7 Orders and Payments
|
||||
|
||||
## Objective
|
||||
Convert checkout snapshots into durable orders and establish authoritative payment, invoice, refund, and return lifecycles without delivery or rewards.
|
||||
|
||||
## Existing Components Reused
|
||||
Checkout snapshots, inventory reservations, pricing money helpers, coupons, ReferenceNumber, document infrastructure, audit queue, authentication, and permissions.
|
||||
|
||||
## Order Architecture
|
||||
Order/payment/fulfillment states are independent. Commercial and address/shipping details are immutable snapshots.
|
||||
|
||||
## Checkout Conversion
|
||||
The centralized transaction locks an owned READY checkout, verifies reservations, returns any existing order, copies items, and converts checkout/cart.
|
||||
|
||||
## Order State Machine
|
||||
Explicit transition sets reject illegal terminal-state transitions and arbitrary status patches.
|
||||
|
||||
## Order Snapshots
|
||||
Items retain product/variant IDs, reservation key, SKU, names, quantity, unit price, discount, total, currency, and limited metadata.
|
||||
|
||||
## Payment Architecture
|
||||
Payments retain immutable attempts. Only server/provider reconciliation changes authoritative financial state.
|
||||
|
||||
## Provider Adapters
|
||||
Generic payment logic delegates verification/parsing/initiation/refund/status behavior to provider modules.
|
||||
|
||||
## PayHere
|
||||
Merchant secrets are environmental. Browser redirects are non-authoritative; the notify hash, reference, amount, and currency must validate.
|
||||
|
||||
## Stripe
|
||||
An explicit disabled boundary is present. Integration awaits official SDK/configuration rather than accepting unverified callbacks.
|
||||
|
||||
## Webhook Verification
|
||||
Invalid signatures, currencies, amounts, and references are rejected.
|
||||
|
||||
## Webhook Idempotency
|
||||
Unique provider/event records and locked payment/order rows ensure duplicate success cannot repeat side effects.
|
||||
|
||||
## Inventory Consumption
|
||||
Checkout reserves; confirmed online payment consumes. Failed payment does not consume. Unpaid cancellation releases.
|
||||
|
||||
## Payment Reconciliation
|
||||
Webhook persistence supports reconciliation, but remote status polling is deferred until the enabled provider supplies a validated status API.
|
||||
|
||||
## Business Credit
|
||||
Disabled pending an approved immutable credit-ledger/accounting policy; concurrent unsafe balance mutation was not introduced.
|
||||
|
||||
## Invoice Architecture
|
||||
One invoice per order uses transaction-safe ReferenceNumber sequencing. The existing document worker remains the PDF integration point.
|
||||
|
||||
## Refund Architecture
|
||||
Idempotent itemized requests validate remaining quantity and captured amount. Provider processing is separate from request approval.
|
||||
|
||||
## Return/RMA Architecture
|
||||
Owner-scoped requests and explicit admin transitions track physical receipt/condition independently from refunds.
|
||||
|
||||
## Exchange Boundary
|
||||
EXCHANGE is recorded as resolution intent; no replacement order or fulfillment is created.
|
||||
|
||||
## Coupon Redemption
|
||||
Redemption occurs once on confirmed payment while the coupon row is locked. Full-refund restoration is deferred by policy.
|
||||
|
||||
## Verified Purchase Reviews
|
||||
Review creation derives verification only from an actual paid order containing the product.
|
||||
|
||||
## Permissions
|
||||
Orders read/manage/cancel, payments read/manage/refund, invoices read, and returns read/manage were added.
|
||||
|
||||
## Audit Events
|
||||
Order, payment creation, cancellation, refund request, return lifecycle, and admin operations reuse sanitized Phase 2 activity logging.
|
||||
|
||||
## Database Changes
|
||||
Forward-only migration `20260909070000` creates eleven commerce tables with unique references/idempotency and lifecycle indexes.
|
||||
|
||||
## Tests
|
||||
Unit tests cover order transitions, PayHere signature validation, forged notifications, secure return/refund inputs, and configurable return eligibility.
|
||||
|
||||
## Remaining Known Issues
|
||||
Real PayHere, refunds, document generation, migration, webhook delivery, and MySQL concurrency were not exercised. Refund approval/provider completion APIs and reconciliation polling require provider policy/configuration.
|
||||
|
||||
## Phase 8 Prerequisites
|
||||
Apply all migrations on restored staging, seed permissions, configure/validate PayHere sandbox, test concurrent duplicate webhooks, final-stock payment consumption, coupon quotas, invoice jobs, refund callbacks, and return restocking.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded and planned as a separate service.
|
||||
@@ -0,0 +1,87 @@
|
||||
# ZUMRI Phase 8 Delivery and Rider Management
|
||||
|
||||
## Objective
|
||||
Add physical delivery and approved-return transportation after the paid Order boundary.
|
||||
|
||||
## Existing Components Reused
|
||||
User/RIDER authentication, Order/OrderItem snapshots, ReturnRequest, ReferenceNumber, Upload, audit, notification foundations, and permissions.
|
||||
|
||||
## Shipment Architecture
|
||||
Orders can have many CUSTOMER_DELIVERY shipments; returns have one RETURN_PICKUP. Shipment owns immutable destination and operational timestamps, never prices or payments.
|
||||
|
||||
## Shipment Items
|
||||
ShipmentItem references OrderItem and supports partial allocation. Locked allocation checks prevent non-cancelled totals exceeding purchases.
|
||||
|
||||
## Shipment State Machine
|
||||
Central legal transitions cover readiness, assignment, pickup, transit, delivery, failure, rescheduling, and cancellation. No generic status patch exists.
|
||||
|
||||
## Order Fulfillment Integration
|
||||
Delivered shipment quantities derive UNFULFILLED, PARTIALLY_FULFILLED, or FULFILLED. Delivery never changes payment or initiates a refund.
|
||||
|
||||
## Rider Profile
|
||||
Operational data attaches one-to-one to an existing RIDER User; authentication and identity remain on User.
|
||||
|
||||
## Rider Availability
|
||||
ACTIVE/INACTIVE/SUSPENDED is separate from AVAILABLE/BUSY/OFFLINE. Assignment locks the rider and checks active capacity.
|
||||
|
||||
## Assignment Architecture
|
||||
Append-preserved assignment rows record assignment, acceptance/rejection, unassignment, and completion. Shipment caches only the current rider.
|
||||
|
||||
## Dispatch
|
||||
The dispatch view queries ready, active, failed, and rescheduled shipments without adding redundant persistence.
|
||||
|
||||
## Rider Workflow
|
||||
Riders see and act only on their own current assignments through explicit legal action endpoints.
|
||||
|
||||
## Delivery Events
|
||||
ShipmentEvent is append-only. Unique event IDs provide retry idempotency and chronological customer timelines.
|
||||
|
||||
## Proof of Delivery
|
||||
Structured proof supports photo, signature, recipient confirmation, and return-pickup photo. Existing private Upload records are ownership/status/MIME checked.
|
||||
|
||||
## Failed Deliveries
|
||||
Failure requires a reason code and increments attempts. It does not refund, cancel the Order, or restock.
|
||||
|
||||
## Rescheduling
|
||||
Admin may schedule a failed delivery with bounded window/reason fields; no calendar optimizer is introduced.
|
||||
|
||||
## Customer Tracking
|
||||
Authenticated ownership is mandatory. Safe projections exclude private rider/location and financial data.
|
||||
|
||||
## Location Privacy
|
||||
Only latest optional coordinates are retained. They are operational data, absent from customer responses and broad audit metadata.
|
||||
|
||||
## Return Logistics
|
||||
Physical transport remains separate from Phase 7 inspection, refund, and inventory decisions.
|
||||
|
||||
## Return Pickup
|
||||
Only approved returns create an idempotent pickup. Delivery marks the request RECEIVED without premature refund/restock.
|
||||
|
||||
## Notifications
|
||||
Existing Phase 2 infrastructure remains the integration boundary. Automated delivery notification fan-out needs durable outbox/worker hardening before production.
|
||||
|
||||
## Permissions
|
||||
Shipment read/create/manage/assign, rider read/manage, dispatch read/manage, proof read, and return-logistics read/manage permissions were added.
|
||||
|
||||
## Audit
|
||||
Creation, assignment, rider actions, cancellation, reschedule, and return pickup use stable Phase 2 activity types without precise location.
|
||||
|
||||
## Idempotency
|
||||
Creation, assignment, and rider transitions use client keys mapped to unique ShipmentEvent IDs. Repeated delivery cannot duplicate proof/event/fulfillment changes.
|
||||
|
||||
## Concurrency
|
||||
Transactions and row locks protect creation allocations, assignment, capacity, transitions, completion, and return-pickup uniqueness. Real multi-connection InnoDB tests remain required.
|
||||
|
||||
## Database Changes
|
||||
Forward-only migration `20260909080000` creates six logistics tables with reference, event, lookup, and uniqueness indexes.
|
||||
|
||||
## Tests
|
||||
Unit tests cover all primary legal/illegal transitions, owner-derived rider authorization, address injection rejection, failure reason validation, and location ranges.
|
||||
|
||||
## Remaining Known Issues
|
||||
Real migrations/concurrency, durable notification fan-out, upload storage, pagination tuning, and assignment race testing require staging. No external courier or real-time GPS system exists.
|
||||
|
||||
## Phase 9 Prerequisites
|
||||
Apply migrations on restored staging, seed permissions/rider profiles, validate real concurrent allocation/assignment/delivery requests, proof uploads, notification idempotency, and return receipt handoff.
|
||||
|
||||
Module 16 AI Customer Support Chatbot remains intentionally outside this backend and will be developed separately.
|
||||
@@ -0,0 +1,90 @@
|
||||
# ZUMRI Phase 9 Loyalty and Wholesale Completion
|
||||
|
||||
## Objective
|
||||
Connect immutable loyalty rewards and wholesale credit/settlement accounting to existing users, paid orders, reviews, coupons, payments, and invoices.
|
||||
|
||||
## Existing Components Reused
|
||||
User/Profile DOB, BusinessCustomer/Tier/CreditAccount/SettlementTerm, Orders, Payments, Refunds, Invoices, Reviews, Coupons, pricing money helpers, ReferenceNumber, cron, notifications, audit, and RBAC.
|
||||
|
||||
## Loyalty Architecture
|
||||
One account caches balances while an append-only ledger remains authoritative. All mutations lock the account and use unique events.
|
||||
|
||||
## Loyalty Account
|
||||
ACTIVE/SUSPENDED/CLOSED state, spendable/pending/debt balances, lifetime totals, tier, and last activity are maintained transactionally.
|
||||
|
||||
## Points Ledger
|
||||
Integer EARN, REDEEM, EXPIRE, ADJUSTMENT, and REVERSAL entries store source, resulting balance, expiry, and sanitized metadata.
|
||||
|
||||
## Earning Rules
|
||||
Data-driven effective rules support amount-based purchases and fixed review/referral/birthday awards.
|
||||
|
||||
## Purchase Rewards
|
||||
Confirmed PAID processing awards discounted merchandise value only, with integer-safe floor rounding and deterministic Order event identity.
|
||||
|
||||
## Refund Reversals
|
||||
Reversals append history. Insufficient available points produce tracked debt rather than silently discarding value.
|
||||
|
||||
## Verified Review Rewards
|
||||
Only APPROVED verified-purchase reviews earn, once per review.
|
||||
|
||||
## Referral Rewards
|
||||
Opaque random codes prevent self/multiple referral claims; signup alone does not reward, while a qualifying paid Order does.
|
||||
|
||||
## Birthday Rewards
|
||||
Daily bounded reconciliation uses a unique user/calendar-year event, preventing DOB edits or retries from duplicating rewards.
|
||||
|
||||
## Points Expiry
|
||||
Earn rules may assign expiry. Allocation records support earliest-expiring-first redemption and idempotent expiry debits.
|
||||
|
||||
## Membership Tiers
|
||||
Active data-driven LIFETIME_POINTS thresholds select the highest rank and append tier history.
|
||||
|
||||
## Reward Catalogue
|
||||
Scheduled, limited COUPON or MANUAL rewards use integer point costs.
|
||||
|
||||
## Reward Redemption
|
||||
Account/reward/allocation locks enforce balance, stock, per-user limits, FIFO spending, and idempotency.
|
||||
|
||||
## Voucher Integration
|
||||
COUPON rewards reference Phase 5 Coupon and issue customer entitlements rather than creating another coupon engine.
|
||||
|
||||
## Loyalty Notifications and Permissions
|
||||
Phase 2 notification/audit remain the delivery boundaries. Accounts read, points adjustment, management, and referral-read permissions were added.
|
||||
|
||||
## Wholesale Architecture
|
||||
Existing business identity/pricing remain authoritative. Phase 9 adds financial ledgers and period snapshots.
|
||||
|
||||
## Business Credit Ledger
|
||||
Unique immutable events store amount, balance delta/after, currency, Order/Payment references, and event time.
|
||||
|
||||
## Credit Utilization
|
||||
Account row locks prevent limits being exceeded. Cached `usedCredit` was not added to BusinessCreditAccount; the latest ledger balance is authoritative.
|
||||
|
||||
## Credit Purchase Integration
|
||||
An eligible pending wholesale Order is captured, inventory-consumed, paid, and invoiced in one transaction.
|
||||
|
||||
## Settlement Terms and Lifecycle
|
||||
Existing term days derive due dates. DRAFT, ISSUED, PARTIALLY_PAID, PAID, OVERDUE, and CANCELLED use explicit transitions.
|
||||
|
||||
## Business Statements, Invoice Integration, Payment Allocation
|
||||
Settlement items reference source Orders/Invoices/ledger entries. Existing Invoice and Payment are reused; allocation persistence prepares later bank matching.
|
||||
|
||||
## Wholesale Dashboard and Analytics
|
||||
Owner-scoped SQL aggregates provide monthly/lifetime volume, discount totals, credit utilization, next settlement, and recent Orders without loading all history.
|
||||
|
||||
## Security, Audit, Idempotency, Concurrency
|
||||
No IDs select another organization on business routes. Strict schemas reject balance/tier/client financial fields. Unique event keys plus account/settlement/reward locks protect retries and competing writes.
|
||||
|
||||
## Database Changes
|
||||
Forward-only `20260909090000` creates fourteen focused loyalty/wholesale tables. Earlier migrations are unchanged.
|
||||
|
||||
## Tests
|
||||
Policy tests cover integer money conversion, configured caps, referral entropy, strict inputs, terminal settlement state, and permission denial, alongside Phase 0–8 regression.
|
||||
|
||||
## Remaining Known Issues
|
||||
Refund-completion reversal wiring, coupon-entitlement enforcement in the Phase 6 quote path, payment allocation APIs, settlement document generation, notification fan-out, and broader integration/concurrency tests require hardening.
|
||||
|
||||
## Phase 10 Prerequisites
|
||||
Run legacy loyalty/credit/voucher/DOB/reference prechecks, migrate restored staging, seed rules/tiers/rewards/permissions, and test real concurrent redemptions, credit purchases, settlements, webhook rewards, refunds, birthday, and expiry jobs.
|
||||
|
||||
Module 16 AI Customer Support Chatbot is intentionally excluded and will be developed as a separate service.
|
||||
@@ -47,6 +47,12 @@ const envSchema = z.object({
|
||||
INVENTORY_RESERVATION_TTL_MINUTES: z.coerce.number().int().positive().default(15),
|
||||
CHECKOUT_TTL_MINUTES: z.coerce.number().int().positive().default(15),
|
||||
CART_MAX_ITEM_QUANTITY: z.coerce.number().int().positive().default(100),
|
||||
RETURN_WINDOW_DAYS: z.coerce.number().int().positive().default(30),
|
||||
PAYHERE_MERCHANT_ID: z.string().optional(), PAYHERE_MERCHANT_SECRET: z.string().optional(),
|
||||
PAYHERE_NOTIFY_URL: z.string().url().optional(), PAYHERE_RETURN_URL: z.string().url().optional(), PAYHERE_CANCEL_URL: z.string().url().optional(),
|
||||
LOYALTY_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
|
||||
SUPPORT_SLA_RECONCILIATION_BATCH_SIZE: z.coerce.number().int().positive().max(1000).default(100),
|
||||
RECOMMENDATION_EVENT_RETENTION_DAYS: z.coerce.number().int().positive().default(90),
|
||||
LOG_RETENTION_DAYS: z.coerce.number().int().positive().default(30),
|
||||
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
|
||||
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
|
||||
|
||||
@@ -25,4 +25,8 @@ module.exports = {
|
||||
INVENTORY_READ:"inventory.read",INVENTORY_ADJUST:"inventory.adjust",INVENTORY_TRANSFER:"inventory.transfer",INVENTORY_RESERVATIONS_READ:"inventory.reservations.read",INVENTORY_WAREHOUSES_MANAGE:"inventory.warehouses.manage",
|
||||
BUSINESS_PRICING_READ:"pricing.business.read",BUSINESS_PRICING_MANAGE:"pricing.business.manage",PROMOTIONS_READ:"promotions.read",PROMOTIONS_MANAGE:"promotions.manage",BANNERS_MANAGE:"merchandising.banners.manage",
|
||||
SHIPPING_ZONES_READ:"shipping.zones.read",SHIPPING_ZONES_MANAGE:"shipping.zones.manage",SHIPPING_METHODS_READ:"shipping.methods.read",SHIPPING_METHODS_MANAGE:"shipping.methods.manage",SHIPPING_RATES_READ:"shipping.rates.read",SHIPPING_RATES_MANAGE:"shipping.rates.manage",
|
||||
ORDERS_READ:"orders.read",ORDERS_MANAGE:"orders.manage",ORDERS_CANCEL:"orders.cancel",PAYMENTS_READ:"payments.read",PAYMENTS_MANAGE:"payments.manage",PAYMENTS_REFUND:"payments.refund",INVOICES_READ:"invoices.read",RETURNS_READ:"returns.read",RETURNS_MANAGE:"returns.manage",
|
||||
SHIPMENTS_READ:"shipments.read",SHIPMENTS_CREATE:"shipments.create",SHIPMENTS_MANAGE:"shipments.manage",SHIPMENTS_ASSIGN:"shipments.assign",RIDERS_READ:"riders.read",RIDERS_MANAGE:"riders.manage",DISPATCH_READ:"dispatch.read",DISPATCH_MANAGE:"dispatch.manage",DELIVERY_PROOF_READ:"delivery.proof.read",RETURNS_LOGISTICS_READ:"returns.logistics.read",RETURNS_LOGISTICS_MANAGE:"returns.logistics.manage",
|
||||
LOYALTY_ACCOUNTS_READ:"loyalty.accounts.read",LOYALTY_POINTS_ADJUST:"loyalty.points.adjust",LOYALTY_MANAGE:"loyalty.manage",LOYALTY_REFERRALS_READ:"loyalty.referrals.read",WHOLESALE_CREDIT_READ:"wholesale.credit.read",WHOLESALE_CREDIT_MANAGE:"wholesale.credit.manage",WHOLESALE_SETTLEMENTS_READ:"wholesale.settlements.read",WHOLESALE_SETTLEMENTS_MANAGE:"wholesale.settlements.manage",WHOLESALE_ANALYTICS_READ:"wholesale.analytics.read",
|
||||
SUPPORT_TICKETS_READ:"support.tickets.read",SUPPORT_TICKETS_ASSIGN:"support.tickets.assign",SUPPORT_TICKETS_REPLY:"support.tickets.reply",SUPPORT_TICKETS_STATUS:"support.tickets.status",SUPPORT_TICKETS_PRIORITY:"support.tickets.priority",SUPPORT_TICKETS_INTERNAL_NOTES:"support.tickets.internal_notes",SUPPORT_TICKETS_ESCALATE:"support.tickets.escalate",SUPPORT_CATEGORIES_MANAGE:"support.categories.manage",SUPPORT_SLA_MANAGE:"support.sla.manage",HELP_READ:"help.read",HELP_MANAGE:"help.manage",HELP_PUBLISH:"help.publish",NEWSLETTER_SUBSCRIBERS_READ:"newsletter.subscribers.read",NEWSLETTER_SUBSCRIBERS_EXPORT:"newsletter.subscribers.export",NEWSLETTER_SUBSCRIBERS_MANAGE:"newsletter.subscribers.manage",RECOMMENDATIONS_READ:"recommendations.read",RECOMMENDATIONS_MANAGE:"recommendations.manage",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const crypto=require("crypto");const db=require("../../models");const {logActivity}=require("../../services/activity.service");
|
||||
exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:false});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status}});}catch(e){return next(e);}};
|
||||
const crypto=require("crypto");const db=require("../../models");const loyalty=require("../../services/loyalty/loyalty.service");const {logActivity}=require("../../services/activity.service");
|
||||
exports.create=async(req,res,next)=>{try{if(!["customer","business_customer"].includes(req.user.accountType))return res.status(403).json({success:false,error:{code:"FORBIDDEN",message:"Customer account required"}});const product=await db.Product.findOne({where:{id:req.params.productId,status:"ACTIVE",visibility:"PUBLIC"}});if(!product)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Product not found"}});const purchased=await db.Order.count({where:{user_id:req.user.id,payment_status:"PAID"},include:[{model:db.OrderItem,as:"items",where:{product_id:product.id},attributes:[]}]});const review=await db.ProductReview.create({id:crypto.randomUUID(),product_id:product.id,user_id:req.user.id,rating:req.body.rating,title:req.body.title,body:req.body.body,status:"PENDING",verified_purchase:purchased>0});await logActivity({user:req.user,type:"REVIEW_SUBMITTED",module:"Catalogue",description:"Product review submitted",targetType:"PRODUCT_REVIEW",targetId:review.id,requestId:req.id});return res.status(201).json({success:true,data:{id:review.id,status:review.status,verifiedPurchase:review.verified_purchase}});}catch(e){return next(e);}};
|
||||
exports.listAdmin=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={};if(req.query.status)where.status=req.query.status;const x=await db.ProductReview.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});return res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){return next(e);}};
|
||||
exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}};
|
||||
exports.moderate=async(req,res,next)=>{try{const r=await db.ProductReview.findByPk(req.params.id);if(!r)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Review not found"}});await r.update({status:req.body.status,moderated_by:req.user.id,moderated_at:new Date()});if(r.status==="APPROVED")await loyalty.earnReview(r);await logActivity({user:req.user,type:req.body.status==="APPROVED"?"REVIEW_APPROVED":"REVIEW_REJECTED",module:"Catalogue",description:"Product review moderated",targetType:"PRODUCT_REVIEW",targetId:r.id,requestId:req.id});return res.json({success:true,data:{id:r.id,status:r.status}});}catch(e){return next(e);}};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),orders=require("../../services/commerce/order.service"),returns=require("../../services/commerce/return.service"),refunds=require("../../services/commerce/refund.service"),state=require("../../services/commerce/orderState.service"),{logActivity}=require("../../services/activity.service");exports.orders=async(req,res,next)=>{try{res.json({success:true,data:await db.Order.findAll({include:[{model:db.OrderItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.order=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id,{include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,admin:true,requestId:req.id});await logActivity({user:req.user,type:"ORDER_CANCELLED",module:"Orders",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.processing=async(req,res,next)=>{try{const row=await db.Order.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"PROCESSING");await row.update({status:"PROCESSING"});res.json({success:true,data:row});}catch(e){next(e);}};exports.returns=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.returnAction=status=>async(req,res,next)=>{try{const row=await returns.transition({id:req.params.id,status,actorUserId:req.user.id,conditions:req.body.conditions||[]});await logActivity({user:req.user,type:`RETURN_${status}`,module:"Returns",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};exports.refund=async(req,res,next)=>{try{const result=await refunds.request({orderId:req.params.id,actorUserId:req.user.id,operationKey:req.get("Idempotency-Key"),...req.body});await logActivity({user:req.user,type:"REFUND_REQUESTED",module:"Refunds",targetId:result.refund.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.refund});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,2 @@
|
||||
const db=require("../../models"),orders=require("../../services/commerce/order.service"),payments=require("../../services/commerce/payment.service"),{logActivity}=require("../../services/activity.service");const audit=(req,type,id)=>logActivity({user:req.user,type,module:"Orders",targetType:"ORDER",targetId:id,requestId:req.id});exports.create=async(req,res,next)=>{try{const result=await orders.createFromCheckout({checkoutId:req.body.checkoutId,userId:req.user.id});if(!result.idempotent)await audit(req,"ORDER_CREATED",result.order.id);res.status(result.idempotent?200:201).json({success:true,data:result.order});}catch(e){next(e);}};
|
||||
exports.list=async(req,res,next)=>{try{const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),where={user_id:req.user.id};if(req.query.status)where.status=req.query.status;const x=await db.Order.findAndCountAll({where,limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.Order.findOne({where:{id:req.params.id,user_id:req.user.id},include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments",attributes:{exclude:["failure_message"]}},{model:db.Invoice,as:"invoice"}]});if(!row)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{const row=await orders.cancel({orderId:req.params.id,userId:req.user.id,requestId:req.id});await audit(req,"ORDER_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.payment=async(req,res,next)=>{try{const row=await db.Payment.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}],attributes:{exclude:["failure_message"]}});res.json({success:true,data:row});}catch(e){next(e);}};exports.invoice=async(req,res,next)=>{try{const row=await db.Invoice.findOne({include:[{model:db.Order,as:"order",where:{id:req.params.id,user_id:req.user.id},attributes:[]}]});if(!row)throw Object.assign(new Error("Invoice not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.startPayment=async(req,res,next)=>{try{const row=await payments.create({orderId:req.params.id,userId:req.user.id,...req.body});await audit(req,"PAYMENT_CREATED",row.id);res.status(201).json({success:true,data:{id:row.id,paymentReference:row.payment_reference,provider:row.provider,status:row.status,amount:String(row.amount),currency:row.currency}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),service=require("../../services/commerce/return.service"),{logActivity}=require("../../services/activity.service");exports.request=async(req,res,next)=>{try{const row=await service.request({orderId:req.params.orderId,userId:req.user.id,...req.body});await logActivity({user:req.user,type:"RETURN_REQUESTED",module:"Returns",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{res.json({success:true,data:await db.ReturnRequest.findAll({where:{user_id:req.user.id},order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const row=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!row)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../../services/commerce/payment.service");exports.payhere=async(req,res,next)=>{try{await service.handleWebhook("PAYHERE",req.body);res.status(200).send("OK");}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models"),help=require("../services/help/help.service"),{resolveLocale}=require("../services/catalogue/locale.service");const locale=req=>resolveLocale(req);exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await help.categories(locale(req))});}catch(e){next(e);}};exports.category=async(req,res,next)=>{try{const row=await db.HelpCategory.findOne({where:{slug:req.params.slug,status:"ACTIVE"}});if(!row)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help category not found"}});res.json({success:true,data:{category:(await help.categories(locale(req))).find(x=>x.id===row.id),articles:await help.articles({locale:locale(req),categoryId:row.id})}});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await help.articles({locale:locale(req),categoryId:req.query.category,featured:req.query.featured==null?undefined:req.query.featured==="true",limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};exports.article=async(req,res,next)=>{try{const a=await db.HelpArticle.findOne({where:{slug:req.params.slug,status:"PUBLISHED"}});if(!a)return res.status(404).json({success:false,error:{code:"NOT_FOUND",message:"Help article not found"}});const tr=help.pick(await db.HelpArticleTranslation.findAll({where:{article_id:a.id}}),locale(req));res.json({success:true,data:{id:a.id,slug:a.slug,categoryId:a.category_id,type:a.article_type,title:tr?.title||null,summary:tr?.summary||null,body:tr?.body||null,locale:tr?.locale||locale(req),isFeatured:a.is_featured,publishedAt:a.published_at}});}catch(e){next(e);}};exports.search=async(req,res,next)=>{try{const q=String(req.query.q||"").trim();if(q.length<2||q.length>100)return res.status(400).json({success:false,error:{code:"INVALID_QUERY",message:"q must contain 2-100 characters"}});res.json({success:true,data:await help.articles({locale:locale(req),q,limit:Math.min(+req.query.limit||20,50)})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models"),help=require("../services/help/help.service");exports.categories=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpCategory.findAll({order:[["sort_order","ASC"]]})});}catch(e){next(e);}};exports.createCategory=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createCategory(req.body)});}catch(e){next(e);}};exports.articles=async(req,res,next)=>{try{res.json({success:true,data:await db.HelpArticle.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.createArticle=async(req,res,next)=>{try{res.status(201).json({success:true,data:await help.createArticle(req.user,req.body)});}catch(e){next(e);}};exports.publish=status=>async(req,res,next)=>{try{res.json({success:true,data:await help.publish(req.params.id,req.user,status)});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),state=require("../../services/logistics/shipmentState.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID(),audit=(req,type,id)=>logActivity({user:req.user,type,module:"Logistics",targetType:"SHIPMENT",targetId:id,requestId:req.id});exports.shipments=async(req,res,next)=>{try{const where={};if(req.query.status)where.status=req.query.status;res.json({success:true,data:await db.Shipment.findAll({where,include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.shipment=async(req,res,next)=>{try{const row=await db.Shipment.findByPk(req.params.id,{include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentAssignment,as:"assignments"},{model:db.ShipmentEvent,as:"events"},{model:db.ShipmentProof,as:"proofs"}]});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.create=async(req,res,next)=>{try{const result=await service.createDelivery({...req.body,actorUserId:req.user.id,eventId:`create:${key(req)}`});await audit(req,"SHIPMENT_CREATED",result.shipment.id);res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.returnPickup=async(req,res,next)=>{try{const result=await service.createReturnPickup({...req.body,actorUserId:req.user.id,eventId:`return-pickup:${key(req)}`});res.status(result.idempotent?200:201).json({success:true,data:result.shipment});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{const result=await service.assign({shipmentId:req.params.id,riderId:req.body.riderId,actorUserId:req.user.id,eventId:`assign:${key(req)}`});await audit(req,"RIDER_ASSIGNED",result.shipment.id);res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.unassign=async(req,res,next)=>{try{const row=await service.unassign({shipmentId:req.params.id,actorUserId:req.user.id,eventId:`unassign:${key(req)}`,reason:req.body.note});await audit(req,"RIDER_UNASSIGNED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.reschedule=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});if(!row)throw Object.assign(new Error("Shipment not found"),{status:404,code:"NOT_FOUND"});state.assertTransition(row.status,"RESCHEDULED");await row.update({status:"RESCHEDULED",scheduled_date:req.body.scheduledDate,time_window_start:req.body.timeWindowStart,time_window_end:req.body.timeWindowEnd,notes:req.body.reason},{transaction:t});await db.ShipmentEvent.create({id:crypto.randomUUID(),shipment_id:row.id,event_id:`reschedule:${key(req)}`,type:"SHIPMENT_RESCHEDULED",status:"RESCHEDULED",actor_user_id:req.user.id,note:req.body.reason,occurred_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_RESCHEDULED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.cancel=async(req,res,next)=>{try{let row;await db.sequelize.transaction(async t=>{row=await db.Shipment.findByPk(req.params.id,{transaction:t,lock:t.LOCK.UPDATE});state.assertTransition(row.status,"CANCELLED");await row.update({status:"CANCELLED",cancelled_at:new Date()},{transaction:t});});await audit(req,"SHIPMENT_CANCELLED",row.id);res.json({success:true,data:row});}catch(e){next(e);}};exports.dispatch=async(req,res,next)=>{try{res.json({success:true,data:{unassigned:await db.Shipment.findAll({where:{status:"READY_FOR_ASSIGNMENT"}}),active:await db.Shipment.findAll({where:{status:{[Op.in]:["ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY"]}}}),attention:await db.Shipment.findAll({where:{status:{[Op.in]:["DELIVERY_FAILED","RESCHEDULED"]}}})}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1,4 @@
|
||||
const crypto=require("crypto"),{Op}=require("sequelize"),db=require("../../models"),service=require("../../services/logistics/shipment.service"),{logActivity}=require("../../services/activity.service");const key=req=>req.get("Idempotency-Key")||crypto.randomUUID();exports.list=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});res.json({success:true,data:await db.Shipment.findAll({where:{assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"}],order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.get=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),row=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id},include:[{model:db.ShipmentItem,as:"items"},{model:db.ShipmentEvent,as:"events"}]});if(!row)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:{id:row.id,shipmentNumber:row.shipment_number,recipientName:row.recipient_name,recipientPhone:row.recipient_phone,address:row.address_snapshot,status:row.status,items:row.items,events:row.events}});}catch(e){next(e);}};const action=to=>async(req,res,next)=>{try{const result=await service.riderAction({shipmentId:req.params.id,userId:req.user.id,to,eventId:`${to}:${key(req)}`,note:req.body.note,proof:req.body.type?req.body:undefined});await logActivity({user:req.user,type:`SHIPMENT_${to}`,module:"Logistics",targetId:result.shipment.id,requestId:req.id});res.json({success:true,data:result.shipment});}catch(e){next(e);}};exports.accept=action("PICKUP_PENDING");exports.pickup=action("PICKED_UP");exports.inTransit=action("IN_TRANSIT");exports.outForDelivery=action("OUT_FOR_DELIVERY");exports.deliver=action("DELIVERED");exports.fail=action("DELIVERY_FAILED");exports.location=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}});if(!rider)throw Object.assign(new Error("Active rider required"),{status:403,code:"RIDER_UNAVAILABLE"});await rider.update({current_latitude:req.body.latitude,current_longitude:req.body.longitude,last_location_at:new Date()});res.status(204).end();}catch(e){next(e);}};
|
||||
exports.reject=async(req,res,next)=>{try{const rider=await db.RiderProfile.findOne({where:{user_id:req.user.id,status:"ACTIVE"}}),shipment=rider&&await db.Shipment.findOne({where:{id:req.params.id,assigned_rider_id:rider.id}});if(!shipment)throw Object.assign(new Error("Assigned shipment not found"),{status:404,code:"NOT_FOUND"});const row=await service.unassign({shipmentId:shipment.id,actorUserId:req.user.id,eventId:`reject:${key(req)}`,reason:req.body.note});await db.ShipmentAssignment.update({status:"REJECTED",rejected_at:new Date()},{where:{shipment_id:shipment.id,rider_id:rider.id,status:"UNASSIGNED"}});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.adminUpdate=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id);if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});const b=req.body;await row.update({employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_UPDATED",module:"Logistics",targetId:row.id,requestId:req.id});res.json({success:true,data:row});}catch(e){next(e);}};
|
||||
exports.adminList=async(req,res,next)=>{try{res.json({success:true,data:await db.RiderProfile.findAll({include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]})});}catch(e){next(e);}};exports.adminGet=async(req,res,next)=>{try{const row=await db.RiderProfile.findByPk(req.params.id,{include:[{model:db.User,as:"user",attributes:["id","firstName","lastName","accountStatus"]}]});if(!row)throw Object.assign(new Error("Rider not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.adminCreate=async(req,res,next)=>{try{const user=await db.User.findOne({where:{id:req.body.userId,accountType:"rider"}});if(!user)throw Object.assign(new Error("Existing RIDER user required"),{status:400,code:"RIDER_USER_REQUIRED"});const b=req.body,row=await db.RiderProfile.create({id:crypto.randomUUID(),user_id:user.id,employee_code:b.employeeCode,status:b.status,availability_status:b.availabilityStatus,phone_override:b.phoneOverride,vehicle_type:b.vehicleType,vehicle_registration:b.vehicleRegistration,max_active_assignments:b.maxActiveAssignments});await logActivity({user:req.user,type:"RIDER_CREATED",module:"Logistics",targetId:row.id,requestId:req.id});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.assignments=async(req,res,next)=>{try{res.json({success:true,data:await db.ShipmentAssignment.findAll({where:{rider_id:req.params.id},order:[["assigned_at","DESC"]]})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models");const project=s=>({shipmentNumber:s.shipment_number,type:s.type,status:s.status,shippingMethod:s.shipping_method_name,deliveredAt:s.delivered_at,events:s.events.map(e=>({type:e.type,status:e.status,note:e.note,occurredAt:e.occurred_at}))});exports.order=async(req,res,next)=>{try{const order=await db.Order.findOne({where:{id:req.params.orderId,user_id:req.user.id}});if(!order)throw Object.assign(new Error("Order not found"),{status:404,code:"NOT_FOUND"});const shipments=await db.Shipment.findAll({where:{order_id:order.id,type:"CUSTOMER_DELIVERY"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}],order:[["createdAt","ASC"]]});res.json({success:true,data:{orderNumber:order.order_number,shipments:shipments.map(project)}});}catch(e){next(e);}};exports.return=async(req,res,next)=>{try{const request=await db.ReturnRequest.findOne({where:{id:req.params.id,user_id:req.user.id}});if(!request)throw Object.assign(new Error("Return not found"),{status:404,code:"NOT_FOUND"});const shipment=await db.Shipment.findOne({where:{return_request_id:request.id,type:"RETURN_PICKUP"},include:[{model:db.ShipmentEvent,as:"events",attributes:["type","status","note","occurred_at"]}]});res.json({success:true,data:{returnNumber:request.return_number,shipment:shipment&&project(shipment)}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),{logActivity}=require("../../services/activity.service");const list=model=>async(req,res,next)=>{try{res.json({success:true,data:await model.findAll({order:[["createdAt","DESC"]]})});}catch(e){next(e);}};exports.accounts=list(db.LoyaltyAccount);exports.tiers=list(db.LoyaltyTier);exports.rules=list(db.LoyaltyEarnRule);exports.rewards=list(db.LoyaltyReward);exports.referrals=list(db.Referral);exports.adjust=async(req,res,next)=>{try{const result=await loyalty.post({userId:req.body.userId,eventId:`LOYALTY:ADMIN:${req.get("Idempotency-Key")}`,type:"ADJUSTMENT",sourceType:"ADMIN_ADJUSTMENT",points:req.body.pointsDelta,descriptionCode:req.body.reasonCode,metadata:{note:req.body.note,actorUserId:req.user.id}});await logActivity({user:req.user,type:"LOYALTY_POINTS_ADJUSTED",module:"Loyalty",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result.entry});}catch(e){next(e);}};exports.createTier=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyTier.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,status:b.status,rank:b.rank,qualification_threshold:b.qualificationThreshold,qualification_metric:"LIFETIME_POINTS",benefits_json:b.benefits,sort_order:b.sortOrder});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createRule=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyEarnRule.create({id:crypto.randomUUID(),source_type:b.sourceType,status:b.status,points_per_amount:b.pointsPerAmount,amount_unit:b.amountUnit,fixed_points:b.fixedPoints,minimum_amount:b.minimumAmount,maximum_points:b.maximumPoints,expiry_days:b.expiryDays,effective_from:b.effectiveFrom,effective_to:b.effectiveTo});res.status(201).json({success:true,data:row});}catch(e){next(e);}};exports.createReward=async(req,res,next)=>{try{const b=req.body,row=await db.LoyaltyReward.create({id:crypto.randomUUID(),code:b.code.toUpperCase(),name:b.name,description:b.description,type:b.type,points_cost:b.pointsCost,status:b.status,starts_at:b.startsAt,ends_at:b.endsAt,stock_limit:b.stockLimit,per_user_limit:b.perUserLimit,configuration:b.configuration});res.status(201).json({success:true,data:row});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const{Op}=require("sequelize"),db=require("../../models"),loyalty=require("../../services/loyalty/loyalty.service"),referrals=require("../../services/loyalty/referral.service");exports.summary=async(req,res,next)=>{try{const account=await db.sequelize.transaction(t=>loyalty.accountFor(req.user.id,t)),tier=account.current_tier_id&&await db.LoyaltyTier.findByPk(account.current_tier_id),next=await db.LoyaltyTier.findOne({where:{status:"ACTIVE",qualification_threshold:{[Op.gt]:account.lifetime_points_earned}},order:[["qualification_threshold","ASC"]]});res.json({success:true,data:{status:account.status,availablePoints:account.available_points,pendingPoints:account.pending_points,pointsDebt:account.points_debt,lifetimePointsEarned:account.lifetime_points_earned,tier:tier&&{code:tier.code,name:tier.name,benefits:tier.benefits_json},nextTier:next&&{name:next.name,pointsRequired:next.qualification_threshold-account.lifetime_points_earned}}});}catch(e){next(e);}};exports.history=async(req,res,next)=>{try{const account=await db.LoyaltyAccount.findOne({where:{user_id:req.user.id}});if(!account)return res.json({success:true,data:[],pagination:{page:1,total:0}});const page=Math.max(Number(req.query.page)||1,1),limit=Math.min(Number(req.query.limit)||20,100),x=await db.LoyaltyLedgerEntry.findAndCountAll({where:{loyalty_account_id:account.id},limit,offset:(page-1)*limit,order:[["occurred_at","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.tiers=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyTier.findAll({where:{status:"ACTIVE"},order:[["rank","ASC"]]})});}catch(e){next(e);}};exports.rewards=async(req,res,next)=>{try{res.json({success:true,data:await db.LoyaltyReward.findAll({where:{status:"ACTIVE"},order:[["points_cost","ASC"]]})});}catch(e){next(e);}};exports.redeem=async(req,res,next)=>{try{const result=await loyalty.redeem({userId:req.user.id,rewardId:req.body.rewardId,idempotencyKey:req.get("Idempotency-Key")});res.status(result.idempotent?200:201).json({success:true,data:result.redemption});}catch(e){next(e);}};exports.vouchers=async(req,res,next)=>{try{res.json({success:true,data:await db.CustomerCouponEntitlement.findAll({where:{user_id:req.user.id,status:"ACTIVE"},attributes:{exclude:["user_id"]}})});}catch(e){next(e);}};exports.referral=async(req,res,next)=>{try{res.json({success:true,data:await referrals.getOrCreate(req.user.id)});}catch(e){next(e);}};exports.claimReferral=async(req,res,next)=>{try{res.status(201).json({success:true,data:await referrals.claim({code:req.body.code,userId:req.user.id})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../models"),service=require("../services/marketing/newsletter.service");exports.subscribe=async(req,res,next)=>{try{const x=await service.subscribe({...req.body,userId:req.user?.id});res.status(x.idempotent?200:201).json({success:true,message:"Subscription request processed",data:{status:x.subscription.status}});}catch(e){next(e);}};exports.unsubscribe=async(req,res,next)=>{try{await service.unsubscribe(req.params.token);res.json({success:true,message:"Unsubscribe request processed"});}catch(e){next(e);}};exports.mine=async(req,res,next)=>{try{res.json({success:true,data:await db.NewsletterSubscription.findOne({where:{user_id:req.user.id},attributes:{exclude:["unsubscribe_token_hash"]}})});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||50,100),x=await db.NewsletterSubscription.findAndCountAll({attributes:{exclude:["unsubscribe_token_hash"]},limit,offset:(page-1)*limit,order:[["createdAt","DESC"]]});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../services/recommendation/recommendation.service"),{resolveLocale}=require("../services/catalogue/locale.service");const loc=req=>resolveLocale(req),limit=req=>Math.min(Math.max(+req.query.limit||12,1),24);exports.event=async(req,res,next)=>{try{const x=await service.recordView({...req.body,userId:req.user?.id});res.status(x.created?201:200).json({success:true,data:{accepted:true,idempotent:!x.created}});}catch(e){next(e);}};exports.related=async(req,res,next)=>{try{res.json({success:true,data:await service.related(req.params.productId,loc(req),limit(req))});}catch(e){next(e);}};exports.trending=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"trending",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.popular=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"popular",locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};exports.recent=async(req,res,next)=>{try{res.json({success:true,data:await service.recent(req.user.id,loc(req),limit(req))});}catch(e){next(e);}};exports.forYou=async(req,res,next)=>{try{res.json({success:true,data:await service.ranked({type:"for-you",userId:req.user.id,locale:loc(req),limit:limit(req)})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const{Op}=require("sequelize"),db=require("../../models"),support=require("../../services/support/support.service");exports.list=async(req,res,next)=>{try{const page=Math.max(+req.query.page||1,1),limit=Math.min(+req.query.limit||20,100),where={...(req.query.status&&{status:req.query.status}),...(req.query.priority&&{priority:req.query.priority}),...(req.query.category&&{category_id:req.query.category}),...(req.query.assignedAgentId&&{assigned_agent_id:req.query.assignedAgentId}),...(req.query.business&&{business_customer_id:req.query.business}),...(req.query.unassigned==="true"&&{assigned_agent_id:null}),...(req.query.overdue==="true"&&{resolution_due_at:{[Op.lt]:new Date()},status:{[Op.notIn]:["RESOLVED","CLOSED","CANCELLED"]}})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit,offset:(page-1)*limit});res.json({success:true,data:x.rows,pagination:{page,limit,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await db.SupportTicket.findByPk(req.params.id);if(!ticket)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const[messages,attachments,links,events,escalations]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id},order:[["occurred_at","ASC"]]}),db.SupportEscalation.findAll({where:{ticket_id:ticket.id}})]);res.json({success:true,data:{ticket,messages,attachments,links,events,escalations}});}catch(e){next(e);}};exports.assign=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.body.agentId,req.user)});}catch(e){next(e);}};exports.claim=async(req,res,next)=>{try{res.json({success:true,data:await support.assign(req.params.id,req.user.id,req.user,true)});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.note=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.agentMessage(req.user,req.params.id,req.body,true)});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await support.transition(req.params.id,to,req.user)});}catch(e){next(e);}};exports.priority=async(req,res,next)=>{try{const row=await db.SupportTicket.findByPk(req.params.id);if(!row)return res.status(404).json({success:false,error:{code:"TICKET_NOT_FOUND",message:"Ticket not found"}});const from=row.priority;await row.update({priority:req.body.priority});await db.SupportTicketEvent.create({id:require("crypto").randomUUID(),event_id:require("crypto").randomUUID(),ticket_id:row.id,actor_user_id:req.user.id,type:"PRIORITY_CHANGED",from_value:from,to_value:row.priority,occurred_at:new Date()});res.json({success:true,data:row});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,true)}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),support=require("../../services/support/support.service");const page=req=>({page:Math.max(Number(req.query.page)||1,1),limit:Math.min(Math.max(Number(req.query.limit)||20,1),100)});exports.create=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.createTicket(req.user,req.body)});}catch(e){next(e);}};exports.list=async(req,res,next)=>{try{const p=page(req),where={customer_user_id:req.user.id,...(req.query.status&&{status:req.query.status}),...(req.query.category&&{category_id:req.query.category})},x=await db.SupportTicket.findAndCountAll({where,order:[["createdAt","DESC"]],limit:p.limit,offset:(p.page-1)*p.limit});res.json({success:true,data:x.rows,pagination:{...p,total:x.count}});}catch(e){next(e);}};exports.detail=async(req,res,next)=>{try{const ticket=await support.ownTicket(req.params.id,req.user.id),[messages,attachments,links,events]=await Promise.all([db.SupportMessage.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"},order:[["createdAt","ASC"]]}),db.SupportAttachment.findAll({where:{ticket_id:ticket.id,visibility:"CUSTOMER_VISIBLE"}}),db.SupportTicketLink.findAll({where:{ticket_id:ticket.id}}),db.SupportTicketEvent.findAll({where:{ticket_id:ticket.id,type:["TICKET_CREATED","TICKET_ASSIGNED","TICKET_RESOLVED","TICKET_CLOSED","TICKET_REOPENED"]},attributes:{exclude:["metadata"]},order:[["occurred_at","ASC"]]})]);res.json({success:true,data:{ticket,messages,attachments,links,events}});}catch(e){next(e);}};exports.reply=async(req,res,next)=>{try{res.status(201).json({success:true,data:await support.customerReply(req.user,req.params.id,req.body)});}catch(e){next(e);}};exports.close=async(req,res,next)=>{try{await support.ownTicket(req.params.id,req.user.id);res.json({success:true,data:await support.transition(req.params.id,"CLOSED",req.user)});}catch(e){next(e);}};exports.attachment=async(req,res,next)=>{try{res.json({success:true,data:{url:await support.attachmentUrl(req.params.id,req.params.attachmentId,req.user,false)}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const db=require("../../models"),credit=require("../../services/wholesale/credit.service"),settlements=require("../../services/wholesale/settlement.service"),{logActivity}=require("../../services/activity.service");exports.credit=async(req,res,next)=>{try{if(!req.get("Idempotency-Key"))throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await credit.post({...req.body,currency:req.body.currency.toUpperCase(),eventId:`CREDIT:${req.get("Idempotency-Key")}`,actorUserId:req.user.id});await logActivity({user:req.user,type:`CREDIT_${req.body.type}`,module:"Wholesale",targetId:result.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:result});}catch(e){next(e);}};exports.creditHistory=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessCreditLedgerEntry.findAll({where:{business_customer_id:req.params.businessId},order:[["occurred_at","DESC"]],limit:200})});}catch(e){next(e);}};exports.generate=async(req,res,next)=>{try{const result=await settlements.generate({...req.body,currency:req.body.currency.toUpperCase()});res.status(result.idempotent?200:201).json({success:true,data:result.settlement});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{res.json({success:true,data:await db.BusinessSettlement.findAll({order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.action=to=>async(req,res,next)=>{try{res.json({success:true,data:await settlements.transition(req.params.id,to,req.user.id)});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const service=require("../../services/wholesale/creditPurchase.service"),{logActivity}=require("../../services/activity.service");exports.purchase=async(req,res,next)=>{try{const key=req.get("Idempotency-Key");if(!key)throw Object.assign(new Error("Idempotency-Key is required"),{status:400,code:"IDEMPOTENCY_KEY_REQUIRED"});const result=await service.purchase({orderId:req.params.id,userId:req.user.id,eventId:`CREDIT:ORDER:${key}`});if(!result.idempotent)await logActivity({user:req.user,type:"CREDIT_CAPTURED",module:"Wholesale",targetId:result.credit.entry.id,requestId:req.id});res.status(result.idempotent?200:201).json({success:true,data:{orderId:result.order.id,paymentStatus:result.order.payment_status,credit:result.credit}});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
const{QueryTypes}=require("sequelize"),db=require("../../models"),money=require("../../services/pricing/money");async function business(userId){const row=await db.BusinessCustomer.findOne({where:{user_id:userId,status:"ACTIVE"}});if(!row)throw Object.assign(new Error("Active business account required"),{status:403,code:"BUSINESS_REQUIRED"});return row;}exports.dashboard=async(req,res,next)=>{try{const b=await business(req.user.id),credit=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),last=credit&&await db.BusinessCreditLedgerEntry.findOne({where:{business_credit_account_id:credit.id},order:[["occurred_at","DESC"]]}),used=last?String(last.balance_after):"0.00",tier=b.business_tier_id&&await db.BusinessTier.findByPk(b.business_tier_id),settlement=await db.BusinessSettlement.findOne({where:{business_customer_id:b.business_customer_id,status:["ISSUED","PARTIALLY_PAID","OVERDUE"]},order:[["due_date","ASC"]]}),stats=(await db.sequelize.query("SELECT COALESCE(SUM(grand_total),0) AS lifetimeVolume, COALESCE(SUM(CASE WHEN placed_at >= DATE_FORMAT(UTC_DATE(), '%Y-%m-01') THEN grand_total ELSE 0 END),0) AS monthlyVolume, COALESCE(SUM(discount_total),0) AS discountTotal, COALESCE(SUM(subtotal),0) AS subtotalTotal FROM orders WHERE business_customer_id = :id AND payment_status = 'PAID'",{replacements:{id:b.business_customer_id},type:QueryTypes.SELECT}))[0];res.json({success:true,data:{partnerId:b.partnerId,tier:tier&&{code:tier.code,name:tier.name},credit:credit&&{status:credit.status,currency:credit.currency,limit:String(credit.credit_limit),used,available:money.subtractFloor(credit.credit_limit,used)},monthlyVolume:String(stats.monthlyVolume),lifetimeVolume:String(stats.lifetimeVolume),discountTotal:String(stats.discountTotal),subtotalTotal:String(stats.subtotalTotal),nextSettlement:settlement&&{number:settlement.settlement_number,dueDate:settlement.due_date,balanceDue:String(settlement.balance_due)}}});}catch(e){next(e);}};exports.credit=async(req,res,next)=>{try{const b=await business(req.user.id),account=await db.BusinessCreditAccount.findOne({where:{business_profile_id:b.business_customer_id}}),rows=account?await db.BusinessCreditLedgerEntry.findAll({where:{business_credit_account_id:account.id},order:[["occurred_at","DESC"]],limit:100}):[];res.json({success:true,data:rows});}catch(e){next(e);}};exports.settlements=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.BusinessSettlement.findAll({where:{business_customer_id:b.business_customer_id},order:[["period_end","DESC"]]})});}catch(e){next(e);}};exports.settlement=async(req,res,next)=>{try{const b=await business(req.user.id),row=await db.BusinessSettlement.findOne({where:{id:req.params.id,business_customer_id:b.business_customer_id}});if(!row)throw Object.assign(new Error("Settlement not found"),{status:404,code:"NOT_FOUND"});res.json({success:true,data:row});}catch(e){next(e);}};exports.orders=async(req,res,next)=>{try{const b=await business(req.user.id);res.json({success:true,data:await db.Order.findAll({where:{business_customer_id:b.business_customer_id},order:[["createdAt","DESC"]],limit:20})});}catch(e){next(e);}};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("CouponRedemption",{id:{type:D.STRING,primaryKey:true},coupon_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},order_id:{type:D.STRING,allowNull:false},redeemed_at:{type:D.DATE,allowNull:false}},{tableName:"coupon_redemptions",timestamps:true,indexes:[{unique:true,fields:["coupon_id","order_id"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("Invoice",{id:{type:D.STRING,primaryKey:true},invoice_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.ENUM("ISSUED","VOID"),allowNull:false},currency:{type:D.STRING(3),allowNull:false},subtotal:{type:D.DECIMAL(15,2),allowNull:false},discount_total:{type:D.DECIMAL(15,2),allowNull:false},shipping_amount:{type:D.DECIMAL(15,2),allowNull:false},tax_amount:{type:D.DECIMAL(15,2),allowNull:false},duty_amount:D.DECIMAL(15,2),grand_total:{type:D.DECIMAL(15,2),allowNull:false},issued_at:{type:D.DATE,allowNull:false},paid_at:D.DATE,document_upload_id:D.INTEGER},{tableName:"invoices",timestamps:true});M.associate=db=>M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("Order",{id:{type:D.STRING,primaryKey:true},order_number:{type:D.STRING(80),allowNull:false,unique:true},user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,checkout_session_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.ENUM("PENDING_PAYMENT","PAID","PROCESSING","READY_FOR_FULFILLMENT","PARTIALLY_FULFILLED","FULFILLED","COMPLETED","CANCELLED","REFUND_PENDING","PARTIALLY_REFUNDED","REFUNDED"),allowNull:false,defaultValue:"PENDING_PAYMENT"},payment_status:{type:D.ENUM("PENDING","AUTHORIZED","PAID","FAILED","CANCELLED","PARTIALLY_REFUNDED","REFUNDED"),allowNull:false,defaultValue:"PENDING"},fulfillment_status:{type:D.ENUM("UNFULFILLED","PARTIALLY_FULFILLED","FULFILLED"),allowNull:false,defaultValue:"UNFULFILLED"},currency:{type:D.STRING(3),allowNull:false},subtotal:{type:D.DECIMAL(15,2),allowNull:false},discount_total:{type:D.DECIMAL(15,2),allowNull:false},shipping_amount:{type:D.DECIMAL(15,2),allowNull:false},tax_amount:{type:D.DECIMAL(15,2),allowNull:false},duty_amount:D.DECIMAL(15,2),grand_total:{type:D.DECIMAL(15,2),allowNull:false},shipping_address_snapshot:{type:D.JSON,allowNull:false},billing_address_snapshot:{type:D.JSON,allowNull:false},shipping_method_snapshot:{type:D.JSON,allowNull:false},coupon_code:D.STRING(50),business_partner_snapshot:D.JSON,placed_at:{type:D.DATE,allowNull:false},cancelled_at:D.DATE,completed_at:D.DATE},{tableName:"orders",timestamps:true});M.associate=db=>{M.hasMany(db.OrderItem,{foreignKey:"order_id",as:"items"});M.hasMany(db.Payment,{foreignKey:"order_id",as:"payments"});M.hasOne(db.Invoice,{foreignKey:"order_id",as:"invoice"});};return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("OrderItem",{id:{type:D.STRING,primaryKey:true},order_id:{type:D.STRING,allowNull:false},product_id:{type:D.STRING,allowNull:false},variant_id:{type:D.STRING,allowNull:false},reservation_key:{type:D.STRING(160),allowNull:false},sku:{type:D.STRING(100),allowNull:false},product_name:{type:D.STRING(255),allowNull:false},variant_description:D.STRING(255),quantity:{type:D.INTEGER,allowNull:false},unit_price:{type:D.DECIMAL(15,2),allowNull:false},discount_amount:{type:D.DECIMAL(15,2),allowNull:false},line_total:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},returned_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},refunded_quantity:{type:D.INTEGER,allowNull:false,defaultValue:0},metadata:D.JSON},{tableName:"order_items",timestamps:true,updatedAt:false});M.associate=db=>M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("Payment",{id:{type:D.STRING,primaryKey:true},order_id:{type:D.STRING,allowNull:false},payment_reference:{type:D.STRING(100),allowNull:false,unique:true},provider:{type:D.ENUM("PAYHERE","STRIPE","INTERNAL_CREDIT","CASH","MANUAL"),allowNull:false},method:{type:D.STRING(50),allowNull:false},status:{type:D.ENUM("PENDING","REQUIRES_ACTION","AUTHORIZED","PAID","FAILED","CANCELLED","PARTIALLY_REFUNDED","REFUNDED"),allowNull:false,defaultValue:"PENDING"},currency:{type:D.STRING(3),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},provider_transaction_id:D.STRING,provider_customer_reference:D.STRING,failure_code:D.STRING,failure_message:D.STRING(500),authorized_at:D.DATE,paid_at:D.DATE,failed_at:D.DATE,cancelled_at:D.DATE},{tableName:"payments",timestamps:true});M.associate=db=>{M.hasMany(db.PaymentAttempt,{foreignKey:"payment_id",as:"attempts"});M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});};return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("PaymentAttempt",{id:{type:D.STRING,primaryKey:true},payment_id:{type:D.STRING,allowNull:false},attempt_number:{type:D.INTEGER,allowNull:false},provider_request_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.STRING(40),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},provider_response_code:D.STRING,provider_transaction_id:D.STRING,started_at:{type:D.DATE,allowNull:false},completed_at:D.DATE,metadata:D.JSON},{tableName:"payment_attempts",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("PaymentWebhookEvent",{id:{type:D.STRING,primaryKey:true},provider:{type:D.STRING(30),allowNull:false},provider_event_id:{type:D.STRING(160),allowNull:false},event_type:{type:D.STRING(80),allowNull:false},payload_hash:{type:D.STRING(64),allowNull:false},processing_status:{type:D.ENUM("RECEIVED","PROCESSED","FAILED","IGNORED"),allowNull:false},received_at:{type:D.DATE,allowNull:false},processed_at:D.DATE,error_code:D.STRING},{tableName:"payment_webhook_events",timestamps:true,indexes:[{unique:true,fields:["provider","provider_event_id"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("Refund",{id:{type:D.STRING,primaryKey:true},refund_number:{type:D.STRING(80),allowNull:false,unique:true},operation_key:{type:D.STRING(160),allowNull:false,unique:true},order_id:{type:D.STRING,allowNull:false},payment_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("REQUESTED","APPROVED","PROCESSING","COMPLETED","FAILED","REJECTED","CANCELLED"),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},reason_code:{type:D.STRING(80),allowNull:false},reason_text:D.STRING(500),provider_refund_id:D.STRING,requested_by:{type:D.STRING,allowNull:false},approved_by:D.STRING,requested_at:{type:D.DATE,allowNull:false},processed_at:D.DATE},{tableName:"refunds",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("RefundItem",{id:{type:D.STRING,primaryKey:true},refund_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false}},{tableName:"refund_items",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ReturnItem",{id:{type:D.STRING,primaryKey:true},return_request_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false},resolution:{type:D.ENUM("REFUND","EXCHANGE"),allowNull:false},condition:{type:D.ENUM("PENDING","RESTOCKABLE","DAMAGED","NON_RESTOCKABLE"),allowNull:false,defaultValue:"PENDING"},refund_amount:D.DECIMAL(15,2)},{tableName:"return_items",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ReturnRequest",{id:{type:D.STRING,primaryKey:true},return_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:{type:D.STRING,allowNull:false},user_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("REQUESTED","APPROVED","REJECTED","AWAITING_RETURN","IN_TRANSIT","RECEIVED","INSPECTING","COMPLETED","CANCELLED"),allowNull:false},reason_code:{type:D.STRING(80),allowNull:false},reason_text:D.STRING(500),requested_at:{type:D.DATE,allowNull:false},approved_at:D.DATE,received_at:D.DATE,completed_at:D.DATE},{tableName:"return_requests",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("HelpArticle",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(180),allowNull:false,unique:true},category_id:{type:D.STRING,allowNull:false},article_type:{type:D.ENUM("ARTICLE","FAQ"),allowNull:false,defaultValue:"ARTICLE"},status:{type:D.ENUM("DRAFT","PUBLISHED","ARCHIVED"),allowNull:false,defaultValue:"DRAFT"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},is_featured:{type:D.BOOLEAN,allowNull:false,defaultValue:false},published_at:D.DATE,created_by:{type:D.STRING,allowNull:false},updated_by:D.STRING},{tableName:"help_articles",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("HelpArticleTranslation",{id:{type:D.STRING,primaryKey:true},article_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},title:{type:D.STRING(220),allowNull:false},summary:D.STRING(500),body:{type:D.TEXT("long"),allowNull:false},seo_title:D.STRING(220),seo_description:D.STRING(500)},{tableName:"help_article_translations",timestamps:true,indexes:[{unique:true,fields:["article_id","locale"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("HelpCategory",{id:{type:D.STRING,primaryKey:true},slug:{type:D.STRING(160),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0},icon_key:D.STRING(80)},{tableName:"help_categories",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("HelpCategoryTranslation",{id:{type:D.STRING,primaryKey:true},category_id:{type:D.STRING,allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false},name:{type:D.STRING(160),allowNull:false},description:D.STRING(500)},{tableName:"help_category_translations",timestamps:true,indexes:[{unique:true,fields:["category_id","locale"]}]});
|
||||
+287
-51
@@ -9,24 +9,19 @@
|
||||
|
||||
// app/models/index.js
|
||||
|
||||
|
||||
const { Sequelize, DataTypes } = require("sequelize");
|
||||
const dbConfig = require("../config/db.config");
|
||||
|
||||
const loggingOption = process.env.NODE_ENV === "development" ? console.log : false;
|
||||
const loggingOption =
|
||||
process.env.NODE_ENV === "development" ? console.log : false;
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
dbConfig.DB,
|
||||
dbConfig.USER,
|
||||
dbConfig.PASSWORD,
|
||||
{
|
||||
host: dbConfig.HOST,
|
||||
port: dbConfig.PORT,
|
||||
dialect: dbConfig.DIALECT,
|
||||
pool: dbConfig.pool,
|
||||
logging: loggingOption
|
||||
}
|
||||
);
|
||||
const sequelize = new Sequelize(dbConfig.DB, dbConfig.USER, dbConfig.PASSWORD, {
|
||||
host: dbConfig.HOST,
|
||||
port: dbConfig.PORT,
|
||||
dialect: dbConfig.DIALECT,
|
||||
pool: dbConfig.pool,
|
||||
logging: loggingOption,
|
||||
});
|
||||
|
||||
const db = {};
|
||||
|
||||
@@ -34,20 +29,38 @@ db.Sequelize = Sequelize;
|
||||
db.sequelize = sequelize;
|
||||
|
||||
/* Register db models */
|
||||
|
||||
// User and Authentication
|
||||
|
||||
// User and Authentication
|
||||
db.User = require("./user/user.model")(sequelize, DataTypes);
|
||||
db.Customer = require("./user/customer.model")(sequelize, DataTypes);
|
||||
db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes);
|
||||
db.BusinessCustomer = require("./user/businessCustomer.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
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.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);
|
||||
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);
|
||||
@@ -55,55 +68,278 @@ db.Upload = require("./upload/upload.model")(sequelize, DataTypes);
|
||||
// Roles and Permissions
|
||||
db.roles = require("./permission/role.model")(sequelize, DataTypes);
|
||||
db.permission = require("./permission/permission.model")(sequelize, DataTypes);
|
||||
db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes);
|
||||
db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes);
|
||||
db.rolePermission = require("./permission/rolePermission.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.userPermission = require("./permission/userPermission.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes);
|
||||
|
||||
// Document Management
|
||||
db.Document = require("./document/document.model")(sequelize, DataTypes);
|
||||
db.DocumentType = require("./document/documentType.model")(sequelize, DataTypes);
|
||||
db.DocumentType = require("./document/documentType.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
|
||||
// Reference Numbers
|
||||
db.referenceNumber = require("./referenceNumbers/referenceNumber.model")(sequelize, DataTypes);
|
||||
db.referenceNumber = require("./referenceNumbers/referenceNumber.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
|
||||
// Notifications
|
||||
db.notification = require("./notification/notification.model")(sequelize, DataTypes);
|
||||
db.userNotification = require("./notification/userNotification.model")(sequelize, DataTypes);
|
||||
db.NotificationDelivery = require("./notification/notificationDelivery.model")(sequelize, DataTypes);
|
||||
db.notification = require("./notification/notification.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.userNotification = require("./notification/userNotification.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.NotificationDelivery = require("./notification/notificationDelivery.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
|
||||
// Catalogue and localized content
|
||||
db.Brand = require("./catalogue/brand.model")(sequelize, DataTypes);
|
||||
db.Category = require("./catalogue/category.model")(sequelize, DataTypes);
|
||||
db.CategoryTranslation = require("./catalogue/categoryTranslation.model")(sequelize, DataTypes);
|
||||
db.CategoryTranslation = require("./catalogue/categoryTranslation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Product = require("./catalogue/product.model")(sequelize, DataTypes);
|
||||
db.ProductTranslation = require("./catalogue/productTranslation.model")(sequelize, DataTypes);
|
||||
db.ProductCategory = require("./catalogue/productCategory.model")(sequelize, DataTypes);
|
||||
db.ProductVariant = require("./catalogue/productVariant.model")(sequelize, DataTypes);
|
||||
db.ProductOption = require("./catalogue/productOption.model")(sequelize, DataTypes);
|
||||
db.ProductOptionValue = require("./catalogue/productOptionValue.model")(sequelize, DataTypes);
|
||||
db.VariantOptionValue = require("./catalogue/variantOptionValue.model")(sequelize, DataTypes);
|
||||
db.ProductAttribute = require("./catalogue/productAttribute.model")(sequelize, DataTypes);
|
||||
db.ProductMedia = require("./catalogue/productMedia.model")(sequelize, DataTypes);
|
||||
db.ProductTranslation = require("./catalogue/productTranslation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductCategory = require("./catalogue/productCategory.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductVariant = require("./catalogue/productVariant.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductOption = require("./catalogue/productOption.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductOptionValue = require("./catalogue/productOptionValue.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.VariantOptionValue = require("./catalogue/variantOptionValue.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductAttribute = require("./catalogue/productAttribute.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductMedia = require("./catalogue/productMedia.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Collection = require("./catalogue/collection.model")(sequelize, DataTypes);
|
||||
db.CollectionTranslation = require("./catalogue/collectionTranslation.model")(sequelize, DataTypes);
|
||||
db.CollectionProduct = require("./catalogue/collectionProduct.model")(sequelize, DataTypes);
|
||||
db.CollectionTranslation = require("./catalogue/collectionTranslation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.CollectionProduct = require("./catalogue/collectionProduct.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.SizeGuide = require("./catalogue/sizeGuide.model")(sequelize, DataTypes);
|
||||
db.ProductRelation = require("./catalogue/productRelation.model")(sequelize, DataTypes);
|
||||
db.ProductReview = require("./catalogue/productReview.model")(sequelize, DataTypes);
|
||||
db.Warehouse=require("./inventory/warehouse.model")(sequelize,DataTypes);db.InventoryBalance=require("./inventory/inventoryBalance.model")(sequelize,DataTypes);db.InventoryTransaction=require("./inventory/inventoryTransaction.model")(sequelize,DataTypes);db.InventoryReservation=require("./inventory/inventoryReservation.model")(sequelize,DataTypes);db.InventoryTransfer=require("./inventory/inventoryTransfer.model")(sequelize,DataTypes);
|
||||
db.BusinessTier=require("./pricing/businessTier.model")(sequelize,DataTypes);db.VariantBusinessPrice=require("./pricing/variantBusinessPrice.model")(sequelize,DataTypes);db.BusinessPriceTier=require("./pricing/businessPriceTier.model")(sequelize,DataTypes);
|
||||
db.Promotion=require("./merchandising/promotion.model")(sequelize,DataTypes);db.PromotionTarget=require("./merchandising/promotionTarget.model")(sequelize,DataTypes);db.Coupon=require("./merchandising/coupon.model")(sequelize,DataTypes);db.Banner=require("./merchandising/banner.model")(sequelize,DataTypes);db.BannerTranslation=require("./merchandising/bannerTranslation.model")(sequelize,DataTypes);
|
||||
db.Cart=require("./shopping/cart.model")(sequelize,DataTypes);db.CartItem=require("./shopping/cartItem.model")(sequelize,DataTypes);db.WishlistItem=require("./shopping/wishlistItem.model")(sequelize,DataTypes);db.CheckoutSession=require("./shopping/checkoutSession.model")(sequelize,DataTypes);db.CheckoutItem=require("./shopping/checkoutItem.model")(sequelize,DataTypes);
|
||||
db.ShippingZone=require("./shipping/shippingZone.model")(sequelize,DataTypes);db.ShippingZoneRegion=require("./shipping/shippingZoneRegion.model")(sequelize,DataTypes);db.ShippingMethod=require("./shipping/shippingMethod.model")(sequelize,DataTypes);db.ShippingRate=require("./shipping/shippingRate.model")(sequelize,DataTypes);
|
||||
db.ProductRelation = require("./catalogue/productRelation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ProductReview = require("./catalogue/productReview.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Warehouse = require("./inventory/warehouse.model")(sequelize, DataTypes);
|
||||
db.InventoryBalance = require("./inventory/inventoryBalance.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.InventoryTransaction = require("./inventory/inventoryTransaction.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.InventoryReservation = require("./inventory/inventoryReservation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.InventoryTransfer = require("./inventory/inventoryTransfer.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.BusinessTier = require("./pricing/businessTier.model")(sequelize, DataTypes);
|
||||
db.VariantBusinessPrice = require("./pricing/variantBusinessPrice.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.BusinessPriceTier = require("./pricing/businessPriceTier.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Promotion = require("./merchandising/promotion.model")(sequelize, DataTypes);
|
||||
db.PromotionTarget = require("./merchandising/promotionTarget.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Coupon = require("./merchandising/coupon.model")(sequelize, DataTypes);
|
||||
db.Banner = require("./merchandising/banner.model")(sequelize, DataTypes);
|
||||
db.BannerTranslation = require("./merchandising/bannerTranslation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Cart = require("./shopping/cart.model")(sequelize, DataTypes);
|
||||
db.CartItem = require("./shopping/cartItem.model")(sequelize, DataTypes);
|
||||
db.WishlistItem = require("./shopping/wishlistItem.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.CheckoutSession = require("./shopping/checkoutSession.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.CheckoutItem = require("./shopping/checkoutItem.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShippingZone = require("./shipping/shippingZone.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShippingZoneRegion = require("./shipping/shippingZoneRegion.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShippingMethod = require("./shipping/shippingMethod.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShippingRate = require("./shipping/shippingRate.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Order = require("./commerce/order.model")(sequelize, DataTypes);
|
||||
db.OrderItem = require("./commerce/orderItem.model")(sequelize, DataTypes);
|
||||
db.Payment = require("./commerce/payment.model")(sequelize, DataTypes);
|
||||
db.PaymentAttempt = require("./commerce/paymentAttempt.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.PaymentWebhookEvent = require("./commerce/paymentWebhookEvent.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Invoice = require("./commerce/invoice.model")(sequelize, DataTypes);
|
||||
db.Refund = require("./commerce/refund.model")(sequelize, DataTypes);
|
||||
db.RefundItem = require("./commerce/refundItem.model")(sequelize, DataTypes);
|
||||
db.ReturnRequest = require("./commerce/returnRequest.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ReturnItem = require("./commerce/returnItem.model")(sequelize, DataTypes);
|
||||
db.CouponRedemption = require("./commerce/couponRedemption.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.RiderProfile = require("./logistics/riderProfile.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Shipment = require("./logistics/shipment.model")(sequelize, DataTypes);
|
||||
db.ShipmentItem = require("./logistics/shipmentItem.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShipmentAssignment = require("./logistics/shipmentAssignment.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShipmentEvent = require("./logistics/shipmentEvent.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.ShipmentProof = require("./logistics/shipmentProof.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyAccount = require("./loyalty/loyaltyAccount.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyTier = require("./loyalty/loyaltyTier.model")(sequelize, DataTypes);
|
||||
db.LoyaltyTierHistory = require("./loyalty/loyaltyTierHistory.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyEarnRule = require("./loyalty/loyaltyEarnRule.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyLedgerEntry = require("./loyalty/loyaltyLedgerEntry.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyPointAllocation = require("./loyalty/loyaltyPointAllocation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyReward = require("./loyalty/loyaltyReward.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.LoyaltyRedemption = require("./loyalty/loyaltyRedemption.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.Referral = require("./loyalty/referral.model")(sequelize, DataTypes);
|
||||
db.CustomerCouponEntitlement =
|
||||
require("./loyalty/customerCouponEntitlement.model")(sequelize, DataTypes);
|
||||
db.BusinessCreditLedgerEntry =
|
||||
require("./wholesale/businessCreditLedgerEntry.model")(sequelize, DataTypes);
|
||||
db.BusinessSettlement = require("./wholesale/businessSettlement.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.BusinessSettlementItem = require("./wholesale/businessSettlementItem.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.PaymentAllocation = require("./wholesale/paymentAllocation.model")(
|
||||
sequelize,
|
||||
DataTypes,
|
||||
);
|
||||
db.SupportCategory = require("./support/supportCategory.model")(sequelize, DataTypes);
|
||||
db.SupportTicket = require("./support/supportTicket.model")(sequelize, DataTypes);
|
||||
db.SupportMessage = require("./support/supportMessage.model")(sequelize, DataTypes);
|
||||
db.SupportTicketEvent = require("./support/supportTicketEvent.model")(sequelize, DataTypes);
|
||||
db.SupportTicketLink = require("./support/supportTicketLink.model")(sequelize, DataTypes);
|
||||
db.SupportAttachment = require("./support/supportAttachment.model")(sequelize, DataTypes);
|
||||
db.SupportSlaPolicy = require("./support/supportSlaPolicy.model")(sequelize, DataTypes);
|
||||
db.SupportEscalation = require("./support/supportEscalation.model")(sequelize, DataTypes);
|
||||
db.HelpCategory = require("./help/helpCategory.model")(sequelize, DataTypes);
|
||||
db.HelpCategoryTranslation = require("./help/helpCategoryTranslation.model")(sequelize, DataTypes);
|
||||
db.HelpArticle = require("./help/helpArticle.model")(sequelize, DataTypes);
|
||||
db.HelpArticleTranslation = require("./help/helpArticleTranslation.model")(sequelize, DataTypes);
|
||||
db.NewsletterSubscription = require("./marketing/newsletterSubscription.model")(sequelize, DataTypes);
|
||||
db.ProductInteractionEvent = require("./recommendation/productInteractionEvent.model")(sequelize, DataTypes);
|
||||
|
||||
/* Associations */
|
||||
Object.keys(db).forEach(model => {
|
||||
Object.keys(db).forEach((model) => {
|
||||
if (db[model].associate) {
|
||||
db[model].associate(db);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
module.exports = db;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("RiderProfile",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false,unique:true},employee_code:{type:D.STRING(60),allowNull:false,unique:true},phone_override:D.STRING(30),vehicle_type:D.STRING(60),vehicle_registration:D.STRING(60),status:{type:D.ENUM("ACTIVE","INACTIVE","SUSPENDED"),allowNull:false,defaultValue:"ACTIVE"},availability_status:{type:D.ENUM("AVAILABLE","BUSY","OFFLINE"),allowNull:false,defaultValue:"OFFLINE"},current_latitude:D.DECIMAL(10,7),current_longitude:D.DECIMAL(10,7),last_location_at:D.DATE,max_active_assignments:{type:D.INTEGER,allowNull:false,defaultValue:1}},{tableName:"rider_profiles",timestamps:true});M.associate=db=>M.belongsTo(db.User,{foreignKey:"user_id",as:"user"});return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("Shipment",{id:{type:D.STRING,primaryKey:true},shipment_number:{type:D.STRING(80),allowNull:false,unique:true},order_id:D.STRING,return_request_id:D.STRING,type:{type:D.ENUM("CUSTOMER_DELIVERY","RETURN_PICKUP"),allowNull:false},status:{type:D.ENUM("PENDING","READY_FOR_ASSIGNMENT","ASSIGNED","PICKUP_PENDING","PICKED_UP","IN_TRANSIT","OUT_FOR_DELIVERY","DELIVERED","DELIVERY_FAILED","RESCHEDULED","CANCELLED"),allowNull:false,defaultValue:"PENDING"},shipping_method_code:D.STRING(60),shipping_method_name:D.STRING(150),recipient_name:{type:D.STRING(150),allowNull:false},recipient_phone:{type:D.STRING(30),allowNull:false},address_snapshot:{type:D.JSON,allowNull:false},assigned_rider_id:D.STRING,assigned_at:D.DATE,picked_up_at:D.DATE,out_for_delivery_at:D.DATE,delivered_at:D.DATE,failed_at:D.DATE,cancelled_at:D.DATE,scheduled_date:D.DATE,time_window_start:D.STRING(20),time_window_end:D.STRING(20),delivery_attempt_count:{type:D.INTEGER,allowNull:false,defaultValue:0},notes:D.STRING(500)},{tableName:"shipments",timestamps:true});M.associate=db=>{M.hasMany(db.ShipmentItem,{foreignKey:"shipment_id",as:"items"});M.hasMany(db.ShipmentAssignment,{foreignKey:"shipment_id",as:"assignments"});M.hasMany(db.ShipmentEvent,{foreignKey:"shipment_id",as:"events"});M.hasMany(db.ShipmentProof,{foreignKey:"shipment_id",as:"proofs"});M.belongsTo(db.Order,{foreignKey:"order_id",as:"order"});};return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ShipmentAssignment",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},rider_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ASSIGNED","ACCEPTED","REJECTED","UNASSIGNED","COMPLETED"),allowNull:false},assigned_by:{type:D.STRING,allowNull:false},assigned_at:{type:D.DATE,allowNull:false},accepted_at:D.DATE,rejected_at:D.DATE,unassigned_at:D.DATE,completed_at:D.DATE,reason:D.STRING(500)},{tableName:"shipment_assignments",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ShipmentEvent",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},event_id:{type:D.STRING(160),allowNull:false,unique:true},type:{type:D.STRING(80),allowNull:false},status:{type:D.STRING(40),allowNull:false},actor_user_id:D.STRING,latitude:D.DECIMAL(10,7),longitude:D.DECIMAL(10,7),note:D.STRING(500),occurred_at:{type:D.DATE,allowNull:false}},{tableName:"shipment_events",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>{const M=s.define("ShipmentItem",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},order_item_id:{type:D.STRING,allowNull:false},quantity:{type:D.INTEGER,allowNull:false}},{tableName:"shipment_items",timestamps:true,indexes:[{unique:true,fields:["shipment_id","order_item_id"]}]});M.associate=db=>M.belongsTo(db.Shipment,{foreignKey:"shipment_id",as:"shipment"});return M;};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ShipmentProof",{id:{type:D.STRING,primaryKey:true},shipment_id:{type:D.STRING,allowNull:false},event_id:{type:D.STRING(160),allowNull:false,unique:true},type:{type:D.ENUM("SIGNATURE","PHOTO","RECIPIENT_CONFIRMATION","RETURN_PICKUP_PHOTO"),allowNull:false},recipient_name:D.STRING(150),signature_upload_id:D.INTEGER,photo_upload_id:D.INTEGER,notes:D.STRING(500),captured_at:{type:D.DATE,allowNull:false},captured_by:{type:D.STRING,allowNull:false}},{tableName:"shipment_proofs",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("CustomerCouponEntitlement",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false},coupon_id:{type:D.STRING,allowNull:false},source_type:{type:D.STRING(40),allowNull:false},source_id:{type:D.STRING,allowNull:false},status:{type:D.ENUM("ACTIVE","USED","EXPIRED","REVOKED"),allowNull:false},issued_at:{type:D.DATE,allowNull:false},used_at:D.DATE,expires_at:D.DATE},{tableName:"customer_coupon_entitlements",timestamps:true,indexes:[{unique:true,fields:["user_id","coupon_id","source_type","source_id"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyAccount",{id:{type:D.STRING,primaryKey:true},user_id:{type:D.STRING,allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","SUSPENDED","CLOSED"),allowNull:false,defaultValue:"ACTIVE"},current_tier_id:D.STRING,lifetime_points_earned:{type:D.INTEGER,allowNull:false,defaultValue:0},lifetime_points_redeemed:{type:D.INTEGER,allowNull:false,defaultValue:0},available_points:{type:D.INTEGER,allowNull:false,defaultValue:0},pending_points:{type:D.INTEGER,allowNull:false,defaultValue:0},points_debt:{type:D.INTEGER,allowNull:false,defaultValue:0},last_activity_at:D.DATE},{tableName:"loyalty_accounts",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyEarnRule",{id:{type:D.STRING,primaryKey:true},source_type:{type:D.ENUM("PURCHASE","VERIFIED_REVIEW","REFERRAL","BIRTHDAY"),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false},points_per_amount:{type:D.INTEGER},amount_unit:D.DECIMAL(15,2),fixed_points:D.INTEGER,minimum_amount:D.DECIMAL(15,2),maximum_points:D.INTEGER,expiry_days:D.INTEGER,effective_from:{type:D.DATE,allowNull:false},effective_to:D.DATE},{tableName:"loyalty_earn_rules",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyLedgerEntry",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},loyalty_account_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("EARN","REDEEM","EXPIRE","ADJUSTMENT","REVERSAL"),allowNull:false},source_type:{type:D.STRING(50),allowNull:false},source_id:D.STRING,points_delta:{type:D.INTEGER,allowNull:false},balance_after:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("POSTED"),allowNull:false,defaultValue:"POSTED"},expires_at:D.DATE,description_code:D.STRING(80),metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false}},{tableName:"loyalty_ledger_entries",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyPointAllocation",{id:{type:D.STRING,primaryKey:true},earn_ledger_id:{type:D.STRING,allowNull:false},loyalty_account_id:{type:D.STRING,allowNull:false},original_points:{type:D.INTEGER,allowNull:false},remaining_points:{type:D.INTEGER,allowNull:false},expires_at:D.DATE},{tableName:"loyalty_point_allocations",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyRedemption",{id:{type:D.STRING,primaryKey:true},redemption_number:{type:D.STRING(80),allowNull:false,unique:true},idempotency_key:{type:D.STRING(160),allowNull:false},loyalty_account_id:{type:D.STRING,allowNull:false},reward_id:{type:D.STRING,allowNull:false},points_cost:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("COMPLETED","CANCELLED"),allowNull:false},fulfilled_reference:D.STRING,redeemed_at:{type:D.DATE,allowNull:false}},{tableName:"loyalty_redemptions",timestamps:true,indexes:[{unique:true,fields:["loyalty_account_id","idempotency_key"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyReward",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(160),allowNull:false},description:D.TEXT,type:{type:D.ENUM("COUPON","MANUAL"),allowNull:false},points_cost:{type:D.INTEGER,allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE","ARCHIVED"),allowNull:false},starts_at:D.DATE,ends_at:D.DATE,stock_limit:D.INTEGER,per_user_limit:D.INTEGER,configuration:D.JSON},{tableName:"loyalty_rewards",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyTier",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},name:{type:D.STRING(120),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},rank:{type:D.INTEGER,allowNull:false},qualification_threshold:{type:D.INTEGER,allowNull:false},qualification_metric:{type:D.ENUM("LIFETIME_POINTS"),allowNull:false,defaultValue:"LIFETIME_POINTS"},benefits_json:D.JSON,sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"loyalty_tiers",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("LoyaltyTierHistory",{id:{type:D.STRING,primaryKey:true},loyalty_account_id:{type:D.STRING,allowNull:false},from_tier_id:D.STRING,to_tier_id:{type:D.STRING,allowNull:false},reason:{type:D.STRING(120),allowNull:false},effective_at:{type:D.DATE,allowNull:false}},{tableName:"loyalty_tier_history",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("Referral",{id:{type:D.STRING,primaryKey:true},referrer_user_id:{type:D.STRING,allowNull:false},referred_user_id:{type:D.STRING,unique:true},referral_code:{type:D.STRING(32),allowNull:false,unique:true},status:{type:D.ENUM("PENDING","REGISTERED","QUALIFIED","REWARDED","REJECTED","EXPIRED"),allowNull:false},qualified_order_id:D.STRING,qualified_at:D.DATE,rewarded_at:D.DATE},{tableName:"referrals",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("NewsletterSubscription",{id:{type:D.STRING,primaryKey:true},email_normalized:{type:D.STRING(254),allowNull:false,unique:true},user_id:D.STRING,status:{type:D.ENUM("PENDING","SUBSCRIBED","UNSUBSCRIBED"),allowNull:false},locale:{type:D.ENUM("en","si","ta"),allowNull:false,defaultValue:"en"},source:{type:D.ENUM("HOME_FOOTER","CHECKOUT","ACCOUNT","ADMIN_IMPORT"),allowNull:false},consented_at:D.DATE,confirmed_at:D.DATE,unsubscribed_at:D.DATE,unsubscribe_token_hash:{type:D.STRING(64),allowNull:false}},{tableName:"newsletter_subscriptions",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("ProductInteractionEvent",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},user_id:D.STRING,session_key_hash:D.STRING(64),product_id:{type:D.STRING,allowNull:false},variant_id:D.STRING,event_type:{type:D.ENUM("PRODUCT_VIEW","PRODUCT_CLICK","WISHLIST_ADD","CART_ADD","CHECKOUT_START","PURCHASE"),allowNull:false},source:{type:D.STRING(40),allowNull:false},occurred_at:{type:D.DATE,allowNull:false}},{tableName:"recommendation_events",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportAttachment",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},message_id:D.STRING,upload_id:{type:D.INTEGER,allowNull:false},uploaded_by:{type:D.STRING,allowNull:false},visibility:{type:D.ENUM("CUSTOMER_VISIBLE","INTERNAL"),allowNull:false,defaultValue:"CUSTOMER_VISIBLE"}},{tableName:"support_attachments",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportCategory",{id:{type:D.STRING,primaryKey:true},code:{type:D.STRING(60),allowNull:false,unique:true},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false,defaultValue:"ACTIVE"},translations:{type:D.JSON,allowNull:false,defaultValue:{}},sort_order:{type:D.INTEGER,allowNull:false,defaultValue:0}},{tableName:"support_categories",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportEscalation",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},ticket_id:{type:D.STRING,allowNull:false},type:{type:D.ENUM("FIRST_RESPONSE_OVERDUE","RESOLUTION_OVERDUE","MANUAL"),allowNull:false},level:{type:D.INTEGER,allowNull:false,defaultValue:1},reason:{type:D.STRING(300),allowNull:false},status:{type:D.ENUM("OPEN","ACKNOWLEDGED"),allowNull:false,defaultValue:"OPEN"},acknowledged_at:D.DATE},{tableName:"support_escalations",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportMessage",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},sender_user_id:D.STRING,sender_type:{type:D.ENUM("CUSTOMER","AGENT","SYSTEM"),allowNull:false},message_type:{type:D.ENUM("MESSAGE","SYSTEM"),allowNull:false,defaultValue:"MESSAGE"},body:{type:D.TEXT,allowNull:false},visibility:{type:D.ENUM("CUSTOMER_VISIBLE","INTERNAL"),allowNull:false,defaultValue:"CUSTOMER_VISIBLE"}},{tableName:"support_messages",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportSlaPolicy",{id:{type:D.STRING,primaryKey:true},name:{type:D.STRING(120),allowNull:false},status:{type:D.ENUM("ACTIVE","INACTIVE"),allowNull:false},priority:{type:D.ENUM("LOW","NORMAL","HIGH","URGENT"),allowNull:false,unique:true},first_response_minutes:{type:D.INTEGER,allowNull:false},resolution_minutes:{type:D.INTEGER,allowNull:false},business_hours_mode:{type:D.ENUM("CLOCK_TIME"),allowNull:false,defaultValue:"CLOCK_TIME"}},{tableName:"support_sla_policies",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportTicket",{id:{type:D.STRING,primaryKey:true},ticket_number:{type:D.STRING(80),allowNull:false,unique:true},customer_user_id:{type:D.STRING,allowNull:false},business_customer_id:D.STRING,subject:{type:D.STRING(200),allowNull:false},category_id:D.STRING,priority:{type:D.ENUM("LOW","NORMAL","HIGH","URGENT"),allowNull:false,defaultValue:"NORMAL"},status:{type:D.ENUM("OPEN","ASSIGNED","WAITING_FOR_CUSTOMER","WAITING_FOR_SUPPORT","RESOLVED","CLOSED","CANCELLED"),allowNull:false,defaultValue:"OPEN"},assigned_agent_id:D.STRING,source:{type:D.ENUM("WEB","MOBILE","ADMIN"),allowNull:false,defaultValue:"WEB"},sla_policy_id:D.STRING,first_response_due_at:D.DATE,resolution_due_at:D.DATE,first_response_at:D.DATE,resolved_at:D.DATE,closed_at:D.DATE,last_customer_message_at:D.DATE,last_agent_message_at:D.DATE},{tableName:"support_tickets",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportTicketEvent",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},ticket_id:{type:D.STRING,allowNull:false},actor_user_id:D.STRING,type:{type:D.STRING(60),allowNull:false},from_value:D.STRING,to_value:D.STRING,metadata:D.JSON,occurred_at:{type:D.DATE,allowNull:false}},{tableName:"support_ticket_events",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("SupportTicketLink",{id:{type:D.STRING,primaryKey:true},ticket_id:{type:D.STRING,allowNull:false},resource_type:{type:D.ENUM("ORDER","PAYMENT","REFUND","RETURN","SHIPMENT","LOYALTY_REDEMPTION","BUSINESS_SETTLEMENT"),allowNull:false},resource_id:{type:D.STRING,allowNull:false}},{tableName:"support_ticket_links",timestamps:true,updatedAt:false,indexes:[{unique:true,fields:["ticket_id","resource_type","resource_id"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("BusinessCreditLedgerEntry",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},business_credit_account_id:{type:D.STRING,allowNull:false},business_customer_id:{type:D.STRING,allowNull:false},order_id:D.STRING,payment_id:D.STRING,type:{type:D.ENUM("AUTHORIZATION","CAPTURE","RELEASE","PAYMENT","REFUND","ADJUSTMENT"),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},balance_delta:{type:D.DECIMAL(15,2),allowNull:false},balance_after:{type:D.DECIMAL(15,2),allowNull:false},reference:D.STRING(160),occurred_at:{type:D.DATE,allowNull:false},metadata:D.JSON},{tableName:"business_credit_ledger_entries",timestamps:true,updatedAt:false});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("BusinessSettlement",{id:{type:D.STRING,primaryKey:true},settlement_number:{type:D.STRING(80),allowNull:false,unique:true},business_customer_id:{type:D.STRING,allowNull:false},period_start:{type:D.DATEONLY,allowNull:false},period_end:{type:D.DATEONLY,allowNull:false},due_date:{type:D.DATEONLY,allowNull:false},status:{type:D.ENUM("DRAFT","ISSUED","PARTIALLY_PAID","PAID","OVERDUE","CANCELLED"),allowNull:false},currency:{type:D.STRING(3),allowNull:false},charge_total:{type:D.DECIMAL(15,2),allowNull:false},payment_total:{type:D.DECIMAL(15,2),allowNull:false},balance_due:{type:D.DECIMAL(15,2),allowNull:false},issued_at:D.DATE,paid_at:D.DATE},{tableName:"business_settlements",timestamps:true,indexes:[{unique:true,fields:["business_customer_id","period_start","period_end"]}]});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("BusinessSettlementItem",{id:{type:D.STRING,primaryKey:true},business_settlement_id:{type:D.STRING,allowNull:false},order_id:D.STRING,invoice_id:D.STRING,credit_ledger_entry_id:D.STRING,description:{type:D.STRING(255),allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false}},{tableName:"business_settlement_items",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
module.exports=(s,D)=>s.define("PaymentAllocation",{id:{type:D.STRING,primaryKey:true},event_id:{type:D.STRING(180),allowNull:false,unique:true},payment_id:{type:D.STRING,allowNull:false},business_settlement_id:{type:D.STRING,allowNull:false},amount:{type:D.DECIMAL(15,2),allowNull:false},currency:{type:D.STRING(3),allowNull:false},allocated_at:{type:D.DATE,allowNull:false},allocated_by:{type:D.STRING,allowNull:false}},{tableName:"payment_allocations",timestamps:true});
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),orders=require("../controllers/commerce/order.controller"),returns=require("../controllers/commerce/return.controller"),webhook=require("../controllers/commerce/webhook.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/commerce.schemas");r.post("/payments/webhooks/payhere",webhook.payhere);r.use(authenticate);r.get("/orders",orders.list);r.post("/orders/from-checkout",validate(s.fromCheckout),orders.create);r.get("/orders/:id",orders.get);r.post("/orders/:id/cancel",orders.cancel);r.post("/orders/:id/payment",validate(s.payment),orders.startPayment);r.get("/orders/:id/payment",orders.payment);r.get("/orders/:id/invoice",orders.invoice);r.post("/orders/:orderId/returns",validate(s.returnRequest),returns.request);r.get("/returns",returns.list);r.get("/returns/:id",returns.get);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/commerce/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/commerce.schemas");r.use(authenticate);r.get("/orders",checkPermission("orders.read",{custom:true}),c.orders);r.get("/orders/:id",checkPermission("orders.read",{custom:true}),c.order);r.post("/orders/:id/cancel",checkPermission("orders.cancel",{custom:true}),c.cancel);r.post("/orders/:id/mark-processing",checkPermission("orders.manage",{custom:true}),c.processing);r.post("/orders/:id/refunds",checkPermission("payments.refund",{custom:true}),validate(s.refund),c.refund);r.get("/returns",checkPermission("returns.read",{custom:true}),c.returns);r.post("/returns/:id/approve",checkPermission("returns.manage",{custom:true}),c.returnAction("APPROVED"));r.post("/returns/:id/reject",checkPermission("returns.manage",{custom:true}),c.returnAction("REJECTED"));r.post("/returns/:id/mark-received",checkPermission("returns.manage",{custom:true}),c.returnAction("RECEIVED"));r.post("/returns/:id/complete",checkPermission("returns.manage",{custom:true}),validate(s.returnComplete),c.returnAction("COMPLETED"));module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../controllers/help.controller"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.get("/help/categories",c.categories);r.get("/help/categories/:slug",c.category);r.get("/help/articles",c.articles);r.get("/help/articles/:slug",c.article);r.get("/help/search",sensitiveLimiter,c.search);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../controllers/helpAdmin.controller"),{authenticate}=require("../middleware/auth.middleware"),{checkPermission}=require("../middleware/permission.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas");r.use(authenticate);r.get("/help/categories",checkPermission("help.read",{custom:true}),c.categories);r.post("/help/categories",checkPermission("help.manage",{custom:true}),validate(s.helpCategory),c.createCategory);r.get("/help/articles",checkPermission("help.read",{custom:true}),c.articles);r.post("/help/articles",checkPermission("help.manage",{custom:true}),validate(s.helpArticle),c.createArticle);r.post("/help/articles/:id/publish",checkPermission("help.publish",{custom:true}),c.publish("PUBLISHED"));r.post("/help/articles/:id/archive",checkPermission("help.publish",{custom:true}),c.publish("ARCHIVED"));module.exports=r;
|
||||
@@ -34,6 +34,21 @@ const pricingAdminRoutes = require("./pricing/admin.routes");
|
||||
const merchandisingPublicRoutes = require("./merchandising/public.routes");
|
||||
const shoppingRoutes = require("./shopping.routes");
|
||||
const shippingAdminRoutes = require("./shipping/admin.routes");
|
||||
const commerceRoutes = require("./commerce.routes");
|
||||
const commerceAdminRoutes = require("./commerce/admin.routes");
|
||||
const logisticsAdminRoutes = require("./logistics/admin.routes");
|
||||
const logisticsRiderRoutes = require("./logistics/rider.routes");
|
||||
const logisticsTrackingRoutes = require("./logistics/tracking.routes");
|
||||
const loyaltyCustomerRoutes = require("./loyalty/customer.routes");
|
||||
const loyaltyAdminRoutes = require("./loyalty/admin.routes");
|
||||
const wholesaleCustomerRoutes = require("./wholesale/customer.routes");
|
||||
const wholesaleAdminRoutes = require("./wholesale/admin.routes");
|
||||
const supportCustomerRoutes = require("./support/customer.routes");
|
||||
const supportAdminRoutes = require("./support/admin.routes");
|
||||
const helpRoutes = require("./help.routes");
|
||||
const helpAdminRoutes = require("./helpAdmin.routes");
|
||||
const newsletterRoutes = require("./newsletter.routes");
|
||||
const recommendationRoutes = require("./recommendation.routes");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -60,5 +75,20 @@ router.use("/admin", pricingAdminRoutes);
|
||||
router.use("/", merchandisingPublicRoutes);
|
||||
router.use("/", shoppingRoutes);
|
||||
router.use("/admin", shippingAdminRoutes);
|
||||
router.use("/", commerceRoutes);
|
||||
router.use("/admin", commerceAdminRoutes);
|
||||
router.use("/admin", logisticsAdminRoutes);
|
||||
router.use("/rider", logisticsRiderRoutes);
|
||||
router.use("/", logisticsTrackingRoutes);
|
||||
router.use("/", loyaltyCustomerRoutes);
|
||||
router.use("/admin", loyaltyAdminRoutes);
|
||||
router.use("/", wholesaleCustomerRoutes);
|
||||
router.use("/admin", wholesaleAdminRoutes);
|
||||
router.use("/", supportCustomerRoutes);
|
||||
router.use("/admin", supportAdminRoutes);
|
||||
router.use("/", helpRoutes);
|
||||
router.use("/admin", helpAdminRoutes);
|
||||
router.use("/", newsletterRoutes);
|
||||
router.use("/", recommendationRoutes);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/logistics/admin.controller"),riders=require("../../controllers/logistics/rider.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/logistics.schemas");r.use(authenticate);r.get("/shipments",checkPermission("shipments.read",{custom:true}),c.shipments);r.get("/shipments/:id",checkPermission("shipments.read",{custom:true}),c.shipment);r.post("/shipments",checkPermission("shipments.create",{custom:true}),validate(s.shipment),c.create);r.post("/shipments/return-pickup",checkPermission("returns.logistics.manage",{custom:true}),validate(s.returnPickup),c.returnPickup);r.post("/shipments/:id/assign",checkPermission("shipments.assign",{custom:true}),validate(s.assign),c.assign);r.post("/shipments/:id/unassign",checkPermission("shipments.assign",{custom:true}),validate(s.note),c.unassign);r.post("/shipments/:id/reschedule",checkPermission("shipments.manage",{custom:true}),validate(s.reschedule),c.reschedule);r.post("/shipments/:id/cancel",checkPermission("shipments.manage",{custom:true}),c.cancel);r.get("/dispatch",checkPermission("dispatch.read",{custom:true}),c.dispatch);r.get("/riders",checkPermission("riders.read",{custom:true}),riders.adminList);r.get("/riders/:id",checkPermission("riders.read",{custom:true}),riders.adminGet);r.post("/riders",checkPermission("riders.manage",{custom:true}),validate(s.rider),riders.adminCreate);r.patch("/riders/:id",checkPermission("riders.manage",{custom:true}),validate(s.rider),riders.adminUpdate);r.get("/riders/:id/assignments",checkPermission("riders.read",{custom:true}),riders.assignments);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/logistics/rider.controller"),{authenticate}=require("../../middleware/auth.middleware"),{authorizedAccountType}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/logistics.schemas");r.use(authenticate,authorizedAccountType(["rider"]));r.get("/shipments",c.list);r.get("/shipments/:id",c.get);r.post("/shipments/:id/accept",validate(s.note),c.accept);r.post("/shipments/:id/reject",validate(s.note),c.reject);r.post("/shipments/:id/pickup",validate(s.note),c.pickup);r.post("/shipments/:id/in-transit",validate(s.note),c.inTransit);r.post("/shipments/:id/out-for-delivery",validate(s.note),c.outForDelivery);r.post("/shipments/:id/deliver",validate(s.proof),c.deliver);r.post("/shipments/:id/fail-delivery",validate(s.reason),c.fail);r.put("/location",validate(s.location),c.location);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/logistics/tracking.controller"),{authenticate}=require("../../middleware/auth.middleware");r.use(authenticate);r.get("/orders/:orderId/tracking",c.order);r.get("/returns/:id/tracking",c.return);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/loyalty/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/loyaltyWholesale.schemas"),idempotency=(req,res,next)=>req.get("Idempotency-Key")?next():res.status(400).json({success:false,error:{code:"IDEMPOTENCY_KEY_REQUIRED",message:"Idempotency-Key is required"}});r.use(authenticate);r.get("/loyalty/accounts",checkPermission("loyalty.accounts.read",{custom:true}),c.accounts);r.post("/loyalty/adjustments",checkPermission("loyalty.points.adjust",{custom:true}),idempotency,validate(s.adjust),c.adjust);r.get("/loyalty/tiers",checkPermission("loyalty.manage",{custom:true}),c.tiers);r.post("/loyalty/tiers",checkPermission("loyalty.manage",{custom:true}),validate(s.tier),c.createTier);r.get("/loyalty/rules",checkPermission("loyalty.manage",{custom:true}),c.rules);r.post("/loyalty/rules",checkPermission("loyalty.manage",{custom:true}),validate(s.rule),c.createRule);r.get("/loyalty/rewards",checkPermission("loyalty.manage",{custom:true}),c.rewards);r.post("/loyalty/rewards",checkPermission("loyalty.manage",{custom:true}),validate(s.reward),c.createReward);r.get("/loyalty/referrals",checkPermission("loyalty.referrals.read",{custom:true}),c.referrals);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/loyalty/customer.controller"),{authenticate}=require("../../middleware/auth.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/loyaltyWholesale.schemas");r.use(authenticate);r.get("/loyalty",c.summary);r.get("/loyalty/history",c.history);r.get("/loyalty/tiers",c.tiers);r.get("/loyalty/rewards",c.rewards);r.post("/loyalty/redeem",validate(s.redeem),c.redeem);r.get("/loyalty/vouchers",c.vouchers);r.get("/loyalty/referral",c.referral);r.post("/loyalty/referral/claim",validate(s.referral),c.claimReferral);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../controllers/newsletter.controller"),{authenticate}=require("../middleware/auth.middleware"),{checkPermission}=require("../middleware/permission.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.post("/newsletter/subscribe",sensitiveLimiter,validate(s.newsletter),c.subscribe);r.post("/newsletter/unsubscribe/:token",sensitiveLimiter,c.unsubscribe);r.get("/newsletter/me",authenticate,c.mine);r.get("/admin/newsletter/subscribers",authenticate,checkPermission("newsletter.subscribers.read",{custom:true}),c.list);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../controllers/recommendation.controller"),{authenticate}=require("../middleware/auth.middleware"),validate=require("../middleware/validate.middleware"),s=require("../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../middleware/rateLimit.middleware");r.post("/recommendations/events",authenticate,sensitiveLimiter,validate(s.viewEvent),c.event);r.get("/products/:productId/recommendations/related",c.related);r.get("/recommendations/trending",c.trending);r.get("/recommendations/popular",c.popular);r.get("/recommendations/recently-viewed",authenticate,c.recent);r.get("/recommendations/for-you",authenticate,c.forYou);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/support/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/supportRecommendation.schemas");r.use(authenticate);r.get("/support/tickets",checkPermission("support.tickets.read",{custom:true}),c.list);r.get("/support/tickets/:id",checkPermission("support.tickets.read",{custom:true}),c.detail);r.post("/support/tickets/:id/assign",checkPermission("support.tickets.assign",{custom:true}),validate(s.assign),c.assign);r.post("/support/tickets/:id/claim",checkPermission("support.tickets.assign",{custom:true}),c.claim);r.post("/support/tickets/:id/messages",checkPermission("support.tickets.reply",{custom:true}),validate(s.message),c.reply);r.post("/support/tickets/:id/internal-notes",checkPermission("support.tickets.internal_notes",{custom:true}),validate(s.internalNote),c.note);r.post("/support/tickets/:id/resolve",checkPermission("support.tickets.status",{custom:true}),c.action("RESOLVED"));r.post("/support/tickets/:id/reopen",checkPermission("support.tickets.status",{custom:true}),c.action("WAITING_FOR_SUPPORT"));r.post("/support/tickets/:id/close",checkPermission("support.tickets.status",{custom:true}),c.action("CLOSED"));r.patch("/support/tickets/:id/priority",checkPermission("support.tickets.priority",{custom:true}),validate(s.priority),c.priority);r.get("/support/tickets/:id/attachments/:attachmentId",checkPermission("support.tickets.read",{custom:true}),c.attachment);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/support/customer.controller"),{authenticate}=require("../../middleware/auth.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/supportRecommendation.schemas"),{sensitiveLimiter}=require("../../middleware/rateLimit.middleware");r.use(authenticate);r.get("/support/tickets",c.list);r.post("/support/tickets",sensitiveLimiter,validate(s.ticketCreate),c.create);r.get("/support/tickets/:id",c.detail);r.post("/support/tickets/:id/messages",sensitiveLimiter,validate(s.message),c.reply);r.post("/support/tickets/:id/close",c.close);r.get("/support/tickets/:id/attachments/:attachmentId",c.attachment);module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/wholesale/admin.controller"),{authenticate}=require("../../middleware/auth.middleware"),{checkPermission}=require("../../middleware/permission.middleware"),validate=require("../../middleware/validate.middleware"),s=require("../../validation/loyaltyWholesale.schemas");r.use(authenticate);r.post("/wholesale/credit/transactions",checkPermission("wholesale.credit.manage",{custom:true}),validate(s.credit),c.credit);r.get("/wholesale/businesses/:businessId/credit",checkPermission("wholesale.credit.read",{custom:true}),c.creditHistory);r.get("/wholesale/settlements",checkPermission("wholesale.settlements.read",{custom:true}),c.settlements);r.post("/wholesale/settlements",checkPermission("wholesale.settlements.manage",{custom:true}),validate(s.settlement),c.generate);r.post("/wholesale/settlements/:id/issue",checkPermission("wholesale.settlements.manage",{custom:true}),c.action("ISSUED"));r.post("/wholesale/settlements/:id/mark-paid",checkPermission("wholesale.settlements.manage",{custom:true}),c.action("PAID"));module.exports=r;
|
||||
@@ -0,0 +1 @@
|
||||
const r=require("express").Router(),c=require("../../controllers/wholesale/customer.controller"),purchase=require("../../controllers/wholesale/creditPurchase.controller"),{authenticate}=require("../../middleware/auth.middleware"),{authorizedAccountType}=require("../../middleware/permission.middleware"),guard=[authenticate,authorizedAccountType(["business_customer"])];r.get("/business/dashboard",guard,c.dashboard);r.get("/business/credit",guard,c.credit);r.get("/business/settlements",guard,c.settlements);r.get("/business/settlements/:id",guard,c.settlement);r.get("/business/orders/recent",guard,c.orders);r.post("/business/orders/:id/use-credit",guard,purchase.purchase);module.exports=r;
|
||||
@@ -0,0 +1,4 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),inventory=require("../inventory/inventory.service"),state=require("./orderState.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});const ref=async(prefix,key,t)=>`${prefix}-${new Date().getUTCFullYear()}-${pad(await nextSequence(key,t),6)}`;
|
||||
async function createFromCheckout({checkoutId,userId}){return db.sequelize.transaction(async transaction=>{const checkout=await db.CheckoutSession.findOne({where:{id:checkoutId,user_id:userId},include:[{model:db.CheckoutItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(!checkout)throw fault("Checkout not found","NOT_FOUND",404);let order=await db.Order.findOne({where:{checkout_session_id:checkout.id},transaction,lock:transaction.LOCK.UPDATE});if(order)return {order,idempotent:true};if(checkout.status!=="READY"||new Date(checkout.expires_at)<=new Date())throw fault("Checkout is not ready","CHECKOUT_NOT_READY",409);for(const item of checkout.items){const reservation=await db.InventoryReservation.findOne({where:{reservation_key:item.reservation_key,status:"ACTIVE"},transaction,lock:transaction.LOCK.UPDATE});if(!reservation)throw fault("Checkout reservation is unavailable","RESERVATION_UNAVAILABLE",409);}order=await db.Order.create({id:crypto.randomUUID(),order_number:await ref("ORD","orders",transaction),user_id:userId,business_customer_id:checkout.business_customer_id,checkout_session_id:checkout.id,status:"PENDING_PAYMENT",payment_status:"PENDING",currency:checkout.currency,subtotal:checkout.subtotal,discount_total:checkout.discount_total,shipping_amount:checkout.shipping_amount,tax_amount:checkout.tax_amount,duty_amount:checkout.duty_amount,grand_total:checkout.grand_total,shipping_address_snapshot:checkout.shipping_address_snapshot,billing_address_snapshot:checkout.billing_address_snapshot,shipping_method_snapshot:checkout.shipping_snapshot,coupon_code:checkout.coupon_code,placed_at:new Date()},{transaction});await db.OrderItem.bulkCreate(checkout.items.map(x=>({id:crypto.randomUUID(),order_id:order.id,product_id:x.product_id,variant_id:x.variant_id,reservation_key:x.reservation_key,sku:x.sku,product_name:x.product_name,variant_description:x.variant_description,quantity:x.quantity,unit_price:x.unit_price,discount_amount:x.discount_amount,line_total:x.line_total,currency:x.currency,metadata:x.metadata})),{transaction});await checkout.update({status:"CONVERTED"},{transaction});await db.Cart.update({status:"CONVERTED"},{where:{id:checkout.cart_id,status:"CHECKOUT_LOCKED"},transaction});return {order,idempotent:false};});}
|
||||
async function cancel({orderId,userId,admin=false,requestId}){return db.sequelize.transaction(async transaction=>{const where={id:orderId,...(!admin&&{user_id:userId})},order=await db.Order.findOne({where,include:[{model:db.OrderItem,as:"items"},{model:db.Payment,as:"payments"}],transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Order not found","NOT_FOUND",404);if(order.status==="CANCELLED")return order;if(order.payment_status==="PAID")throw fault("Paid orders require a refund","REFUND_REQUIRED",409);state.assertTransition(order.status,"CANCELLED");for(const item of order.items)await inventory.releaseReservation({reservationKey:item.reservation_key,requestId,transaction});await db.Payment.update({status:"CANCELLED",cancelled_at:new Date()},{where:{order_id:order.id,status:["PENDING","REQUIRES_ACTION"]},transaction});await order.update({status:"CANCELLED",payment_status:"CANCELLED",cancelled_at:new Date()},{transaction});return order;});}
|
||||
module.exports={createFromCheckout,cancel,ref};
|
||||
@@ -0,0 +1 @@
|
||||
const transitions={PENDING_PAYMENT:new Set(["PAID","CANCELLED"]),PAID:new Set(["PROCESSING","REFUND_PENDING"]),PROCESSING:new Set(["READY_FOR_FULFILLMENT","REFUND_PENDING"]),READY_FOR_FULFILLMENT:new Set(["PARTIALLY_FULFILLED","FULFILLED"]),PARTIALLY_FULFILLED:new Set(["FULFILLED"]),FULFILLED:new Set(["COMPLETED"]),COMPLETED:new Set(["REFUND_PENDING"]),REFUND_PENDING:new Set(["PARTIALLY_REFUNDED","REFUNDED"]),PARTIALLY_REFUNDED:new Set(["REFUND_PENDING","REFUNDED"]),CANCELLED:new Set(),REFUNDED:new Set()};const canTransition=(from,to)=>Boolean(transitions[from]?.has(to));const assertTransition=(from,to)=>{if(!canTransition(from,to))throw Object.assign(new Error(`Illegal order transition ${from} -> ${to}`),{code:"ILLEGAL_ORDER_TRANSITION",status:409});};module.exports={transitions,canTransition,assertTransition};
|
||||
@@ -0,0 +1,6 @@
|
||||
const crypto=require("crypto"),db=require("../../models"),money=require("../pricing/money"),inventory=require("../inventory/inventory.service"),loyalty=require("../loyalty/loyalty.service"),state=require("./orderState.service"),{nextSequence,pad}=require("../../utils/referenceNumber.util"),payhere=require("./providers/payhere.provider");const fault=(m,c,s=400)=>Object.assign(new Error(m),{code:c,status:s});const adapters={PAYHERE:payhere};
|
||||
async function create({orderId,userId,provider="PAYHERE",method="ONLINE"}){if(!adapters[provider])throw fault("Payment provider is unavailable","PROVIDER_NOT_CONFIGURED",503);return db.sequelize.transaction(async transaction=>{const order=await db.Order.findOne({where:{id:orderId,user_id:userId,status:"PENDING_PAYMENT"},transaction,lock:transaction.LOCK.UPDATE});if(!order)throw fault("Payable order not found","NOT_FOUND",404);let payment=await db.Payment.findOne({where:{order_id:order.id,status:["PENDING","REQUIRES_ACTION"]},transaction,lock:transaction.LOCK.UPDATE});if(payment)return payment;payment=await db.Payment.create({id:crypto.randomUUID(),order_id:order.id,payment_reference:order.order_number,provider,method,status:"PENDING",currency:order.currency,amount:order.grand_total},{transaction});await db.PaymentAttempt.create({id:crypto.randomUUID(),payment_id:payment.id,attempt_number:1,provider_request_id:crypto.randomUUID(),status:"STARTED",amount:payment.amount,started_at:new Date(),metadata:{provider}},{transaction});return payment;});}
|
||||
async function redeemCoupon(order,transaction){if(!order.coupon_code)return;const coupon=await db.Coupon.findOne({where:{code:order.coupon_code},transaction,lock:transaction.LOCK.UPDATE});if(!coupon)return;const used=await db.CouponRedemption.count({where:{coupon_id:coupon.id},transaction});if(coupon.max_uses&&used>=coupon.max_uses)throw fault("Coupon usage limit reached","COUPON_LIMIT_REACHED",409);await db.CouponRedemption.findOrCreate({where:{coupon_id:coupon.id,order_id:order.id},defaults:{id:crypto.randomUUID(),user_id:order.user_id,redeemed_at:new Date()},transaction});}
|
||||
async function issueInvoice(order,transaction){let invoice=await db.Invoice.findOne({where:{order_id:order.id},transaction,lock:transaction.LOCK.UPDATE});if(!invoice){const n=await nextSequence("invoices",transaction);invoice=await db.Invoice.create({id:crypto.randomUUID(),invoice_number:`INV-${new Date().getUTCFullYear()}-${pad(n,6)}`,order_id:order.id,status:"ISSUED",currency:order.currency,subtotal:order.subtotal,discount_total:order.discount_total,shipping_amount:order.shipping_amount,tax_amount:order.tax_amount,duty_amount:order.duty_amount,grand_total:order.grand_total,issued_at:new Date(),paid_at:new Date()},{transaction});}await loyalty.earnPurchase(order,transaction);return invoice;}
|
||||
async function handleWebhook(provider,payload){const adapter=adapters[provider];if(!adapter||!adapter.verifyWebhook(payload))throw fault("Invalid webhook signature","INVALID_WEBHOOK_SIGNATURE",401);const event=adapter.parseWebhook(payload),hash=crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex");return db.sequelize.transaction(async transaction=>{let record=await db.PaymentWebhookEvent.findOne({where:{provider,provider_event_id:event.eventId},transaction,lock:transaction.LOCK.UPDATE});if(record?.processing_status==="PROCESSED")return{idempotent:true,status:"PROCESSED"};if(!record)record=await db.PaymentWebhookEvent.create({id:crypto.randomUUID(),provider,provider_event_id:event.eventId,event_type:event.eventType,payload_hash:hash,processing_status:"RECEIVED",received_at:new Date()},{transaction});const payment=await db.Payment.findOne({where:{payment_reference:event.paymentReference,provider},transaction,lock:transaction.LOCK.UPDATE});if(!payment)throw fault("Payment not found","PAYMENT_NOT_FOUND",404);if(payment.currency!==event.currency||money.parse(payment.amount)!==money.parse(event.amount))throw fault("Payment amount or currency mismatch","PAYMENT_MISMATCH",409);const order=await db.Order.findByPk(payment.order_id,{include:[{model:db.OrderItem,as:"items"}],transaction,lock:transaction.LOCK.UPDATE});if(event.status==="PAID"&&payment.status!=="PAID"){state.assertTransition(order.status,"PAID");for(const item of [...order.items].sort((a,b)=>a.variant_id.localeCompare(b.variant_id)))await inventory.consumeReservation({reservationKey:item.reservation_key,requestId:`webhook:${record.id}`,transaction});await redeemCoupon(order,transaction);await payment.update({status:"PAID",provider_transaction_id:event.providerTransactionId,paid_at:new Date()},{transaction});await order.update({status:"PAID",payment_status:"PAID"},{transaction});await issueInvoice(order,transaction);}else if(event.status==="FAILED"&&payment.status!=="PAID"){await payment.update({status:"FAILED",failed_at:new Date(),failure_code:event.eventType},{transaction});await order.update({payment_status:"FAILED"},{transaction});}await record.update({processing_status:"PROCESSED",processed_at:new Date()},{transaction});return{idempotent:false,status:event.status,paymentId:payment.id};});}
|
||||
module.exports={create,handleWebhook,redeemCoupon,issueInvoice,adapters};
|
||||
@@ -0,0 +1,5 @@
|
||||
const crypto=require("crypto"),md5=x=>crypto.createHash("md5").update(String(x)).digest("hex").toUpperCase();
|
||||
function verifyWebhook(p){const secret=process.env.PAYHERE_MERCHANT_SECRET;if(!secret)return false;const expected=md5(`${p.merchant_id}${p.order_id}${p.payhere_amount}${p.payhere_currency}${p.status_code}${md5(secret)}`),actual=String(p.md5sig||"").toUpperCase().padEnd(expected.length,"0").slice(0,expected.length);return crypto.timingSafeEqual(Buffer.from(expected),Buffer.from(actual));}
|
||||
function parseWebhook(p){return{eventId:p.payment_id||`${p.order_id}:${p.status_code}:${p.payhere_amount}`,paymentReference:p.order_id,providerTransactionId:p.payment_id,status:String(p.status_code)==="2"?"PAID":String(p.status_code)==="0"?"PENDING":"FAILED",amount:String(p.payhere_amount),currency:p.payhere_currency,eventType:`PAYHERE_${p.status_code}`};}
|
||||
function createPayment({reference,amount,currency}){const merchantId=process.env.PAYHERE_MERCHANT_ID,secret=process.env.PAYHERE_MERCHANT_SECRET;if(!merchantId||!secret)throw Object.assign(new Error("PayHere is not configured"),{code:"PROVIDER_NOT_CONFIGURED",status:503});return{merchant_id:merchantId,order_id:reference,amount,currency,hash:md5(`${merchantId}${reference}${amount}${currency}${md5(secret)}`),notify_url:process.env.PAYHERE_NOTIFY_URL,return_url:process.env.PAYHERE_RETURN_URL,cancel_url:process.env.PAYHERE_CANCEL_URL};}
|
||||
const unavailable=()=>{throw Object.assign(new Error("Provider operation is not configured"),{code:"PROVIDER_OPERATION_UNAVAILABLE",status:503});};module.exports={createPayment,verifyWebhook,parseWebhook,refund:unavailable,getPaymentStatus:unavailable};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user