/* eslint-env node */
/**
* CartService
*
* - Extends BaseService so all the standard CRUD flows are consistent.
* - Uses CartRepository for carts persistence.
* - Uses CartItemRepository for cart_items persistence (same module boundary).
* - Uses *other modules via their init<Service>Service()* for product reads.
*/
const BaseService = require('../common/BaseService');
const CartEntity = require('./CartEntity');
const CartRepository = require('./CartRepository');
const CartItemRepository = require('./cart-items/CartItemRepository');
const { withPluggable } = require('../common/mixins/service');
// Optional: centralize the allowed entity types we put in cart_items.entity_type
const PRODUCT_TYPES = {
ICON : 'icon',
ILLUSTRATION : 'illustration',
SET : 'set',
FAMILY : 'family',
CREDITS : 'credits',
SUBSCRIPTION : 'subscription',
};
/**
* CartService class
* @extends BaseService, withPluggable, withObservable
*/
class CartService extends withPluggable(BaseService) {
/**
* @param {Object} opts
* @param {CartRepository} [opts.repository] - repository for carts
* @param {Function} [opts.entityClass] - entity class for carts
* @param {CartItemRepository} [opts.cartItemRepository]
* @param {Object} [opts.services] - cross-module service registry (init<Service>Service() instances)
* {
* iconService,
* illustrationService,
* setService,
* familyService,
* creditsService,
* subscriptionService,
* couponCodeService,
* }
*/
constructor({
repository = new CartRepository(),
entityClass = CartEntity,
cartItemRepository = new CartItemRepository(),
services = {},
} = {}) {
super({ repository, entityClass });
this.cartItemRepository = cartItemRepository;
this.services = services;
}
// ---------- Cart reads / writes ----------
/**
* Get a cart by id with user + items.
* Returns raw with relations (or you can wrap if you prefer Entities here).
*/
async getCartWithItems(id, { trx } = {}) {
const [record] = await this.repository.withRelations(
{ id },
'[user, cartItems]',
{ trx }
);
if (!record) {
throw new Error('Cart not found');
}
return record;
}
/**
* Find the user’s pending cart, with items.
* If none, returns null.
*/
async findPendingCartForUser(userId, { trx } = {}) {
const cart = await this.repository.findOne(
{ user_id: userId, status: 'Not processed' },
{ trx, entityClass: this.entityClass }
);
if (!cart) return null;
// Attach cartItems (raw) for convenience
const [withItems] = await this.repository.withRelations(
{ id: cart.id },
'[cartItems]',
{ trx }
);
return withItems || cart;
}
/**
* Find or create a pending cart for the user.
*/
async findOrCreatePendingCart(userId, { trx } = {}) {
const existing = await this.findPendingCartForUser(userId, { trx });
if (existing) return existing;
const created = await this.repository.upsert(
{ user_id: userId, status: 'Not processed', subtotal: 0, tax: 0, discount: 0, total: 0 },
{ user_id: userId, status: 'Not processed' },
{ trx }
);
const [withItems] = await this.repository.withRelations(
{ id: created.id },
'[cartItems]',
{ trx }
);
return withItems || created;
}
// ---------- Cart items ----------
/**
* Add (or upsert) a line item to a user’s pending cart.
* This writes cart_items via our own repository AND updates cart totals.
*/
async addItemToCart({ userId, entityId, entityType, price }, { trx } = {}) {
// Validate type
const type = String(entityType || '').toLowerCase();
if (!Object.values(PRODUCT_TYPES).includes(type)) {
throw new Error(`Unsupported entityType: ${entityType}`);
}
// Resolve the product via the appropriate service
const product = await this.getProductService(type).getById(entityId, { trx });
if (!product) {
throw new Error(`Entity not found: ${entityType} ID ${entityId}`);
}
// Find/create pending cart
const cart = await this.findOrCreatePendingCart(userId, { trx });
// Upsert cart item (idempotent for the same {cart_id, entity_id, entity_type})
await this.cartItemRepository.upsert(
{ cart_id: cart.id, entity_id: entityId, entity_type: type, price },
{ cart_id: cart.id, entity_id: entityId, entity_type: type },
{ trx }
);
// Update totals
await this.updateTotals(cart.id, { trx });
return this.getCartWithItems(cart.id, { trx });
}
/**
* Upsert a batch of items into a user’s pending cart.
*/
async upsertCartWithItems({ userId, cartItems }, { trx } = {}) {
if (!Array.isArray(cartItems) || cartItems.length === 0) {
throw new Error('Items must be a non-empty array');
}
const cart = await this.findOrCreatePendingCart(userId, { trx });
for (const item of cartItems) {
const type = String(item.entity_type || item.entityType || '').toLowerCase();
if (!Object.values(PRODUCT_TYPES).includes(type)) {
throw new Error(`Unsupported entityType: ${item.entity_type || item.entityType}`);
}
// Validate existence via service
const product = await this.getProductService(type).getById(item.entity_id || item.entityId, { trx });
if (!product) {
throw new Error(`Entity not found: ${type} ID ${item.entity_id || item.entityId}`);
}
await this.cartItemRepository.upsert(
{
cart_id: cart.id,
entity_id: item.entity_id || item.entityId,
entity_type: type,
price: item.price,
},
{
cart_id: cart.id,
entity_id: item.entity_id || item.entityId,
entity_type: type,
},
{ trx }
);
}
await this.updateTotals(cart.id, { trx });
return this.getCartWithItems(cart.id, { trx });
}
/**
* Delete a single item and refresh totals.
*/
async deleteCartItem({ cartId, entityId, entityType }, { trx } = {}) {
const type = String(entityType || '').toLowerCase();
await this.cartItemRepository.deleteWhere(
{ cart_id: cartId, entity_id: entityId, entity_type: type },
{ trx }
);
await this.updateTotals(cartId, { trx });
}
/**
* Clear all items and reset totals.
*/
async clearCart(cartId, { trx } = {}) {
await this.cartItemRepository.deleteWhere({ cart_id: cartId }, { trx });
await this.repository.update(cartId, { subtotal: 0, tax: 0, discount: 0, total: 0 }, { trx });
return this.getCartWithItems(cartId, { trx });
}
// ---------- Totals / pricing ----------
/**
* Recompute totals from the current cart_items rows.
* (This is a placeholder; customize your tax/discount strategy as needed.)
*/
async updateTotals(cartId, { trx } = {}) {
const items = await this.cartItemRepository.findAll({ cart_id: cartId }, { trx });
const subtotal = (items || []).reduce((sum, it) => sum + Number(it.price || 0), 0);
const tax = 0;
const discount = 0;
const total = Math.max(subtotal + tax - discount, 0);
await this.repository.update(cartId, { subtotal, tax, discount, total }, { trx });
}
async getCartTotal(cart, { trx } = {}) {
// If the caller passed a cart id, load it with items first
const full = typeof cart === 'number' ? await this.getCartWithItems(cart, { trx }) : cart;
const items = full?.cartItems || [];
return items.reduce((sum, it) => sum + Number(it.price || 0), 0);
}
// ---------- Discounts / coupons ----------
/**
* Apply a coupon to a cart:
* - Validate via couponCodeService
* - Create a “cart” discount row (via CartRepository upsertDiscount if you keep that)
* - Optionally create per-item discount rows
* - Update cart.total
*/
async applyDiscount({ cartId, couponCode }, { trx } = {}) {
const { couponCodeService } = this.services;
if (!couponCodeService) {
throw new Error('couponCodeService not configured');
}
// Load current cart with items
const cart = await this.getCartWithItems(cartId, { trx });
// Validate coupon for this cart (business rules live in the coupon service)
await couponCodeService.validateForCart({
code: couponCode,
cart,
buyer: cart.user, // adjust if you use something else as “buyer”
});
// Compute totals before discount
const originalPrice = await this.getCartTotal(cart, { trx });
// Compute discounted total (simple example; defer logic to coupon service if preferred)
const { discountedPrice, discountAmount } = this.calculateDiscount(
originalPrice,
await couponCodeService.getByCode(couponCode, { trx })
);
// Persist (parent) discount row if you keep a discounts table
if (typeof this.repository.upsertDiscount === 'function') {
const cartDiscount = await this.repository.upsertDiscount({
coupon_code: couponCode,
entity_type: 'cart',
entity_id: cartId,
original_price: originalPrice,
discounted_price: discountedPrice,
discount_amount: discountAmount,
parent_id: null,
}, undefined, { trx });
// Per-item discounts (optional)
await this.applyCartItemsDiscount({ cart, couponCode, parentId: cartDiscount?.id }, { trx });
}
// Update cart total
await this.repository.update(cartId, { total: discountedPrice }, { trx });
return this.getCartWithItems(cartId, { trx });
}
/**
* Create per-item discount rows (optional; depends on your schema).
*/
async applyCartItemsDiscount({ cart, couponCode, parentId }, { trx } = {}) {
if (!Array.isArray(cart?.cartItems) || !this.repository.upsertDiscount) {
return;
}
for (const item of cart.cartItems) {
const price = Number(item.price || 0);
const { discountedPrice, discountAmount } = this.calculateDiscount(
price,
await this.services.couponCodeService.getByCode(couponCode, { trx })
);
await this.repository.upsertDiscount({
coupon_code: couponCode,
entity_type: 'cart_item',
entity_id: item.id,
original_price: price,
discounted_price: discountedPrice,
discount_amount: discountAmount,
parent_id: parentId || null,
}, undefined, { trx });
}
}
/**
* Very simple discount math; most teams move this to the coupon service.
* Expects coupon entities to expose getType()/getAmount() or you adapt here.
*/
calculateDiscount(originalPrice, couponEntity) {
const base = Number(originalPrice || 0);
if (!couponEntity || typeof couponEntity.getType !== 'function') {
return { discountedPrice: base, discountAmount: 0 };
}
const type = couponEntity.getType();
const amt = Number(couponEntity.getAmount ? couponEntity.getAmount() : 0);
if (type === 'percentage') {
const discountAmount = base * (amt / 100);
return { discountedPrice: Math.max(base - discountAmount, 0), discountAmount };
}
if (type === 'fixed') {
const discountAmount = amt;
return { discountedPrice: Math.max(base - discountAmount, 0), discountAmount };
}
return { discountedPrice: base, discountAmount: 0 };
}
// ---------- Helpers ----------
/**
* Map entity_type -> service used to read that product.
* Throws if a service is not configured.
*/
getProductService(type) {
const {
iconService,
illustrationService,
setService,
familyService,
creditsService,
subscriptionService,
} = this.services;
switch (type) {
case PRODUCT_TYPES.ICON: return assertService('iconService', iconService);
case PRODUCT_TYPES.ILLUSTRATION: return assertService('illustrationService', illustrationService);
case PRODUCT_TYPES.SET: return assertService('setService', setService);
case PRODUCT_TYPES.FAMILY: return assertService('familyService', familyService);
case PRODUCT_TYPES.CREDITS: return assertService('creditsService', creditsService);
case PRODUCT_TYPES.SUBSCRIPTION: return assertService('subscriptionService', subscriptionService);
default: throw new Error(`Unsupported entityType: ${type}`);
}
}
}
/**
* Tiny guard to make missing service configs fail loudly & clearly.
*/
const assertService = (name, svc) => {
if (!svc) throw new Error(`${name} not configured`);
return svc;
};
CartService.PRODUCT_TYPES = PRODUCT_TYPES;
module.exports = CartService;
Source