342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
const pool = require('../../database/database');
|
|
const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository');
|
|
|
|
class AbonemmentService {
|
|
constructor() {
|
|
this.repo = new AbonemmentRepository();
|
|
}
|
|
|
|
isAdmin(user) {
|
|
return user && ['admin', 'super_admin'].includes(user.role);
|
|
}
|
|
|
|
addInterval(date, interval, count) {
|
|
const d = new Date(date);
|
|
if (interval === 'day') d.setDate(d.getDate() + count);
|
|
if (interval === 'week') d.setDate(d.getDate() + 7 * count);
|
|
if (interval === 'month') d.setMonth(d.getMonth() + count);
|
|
if (interval === 'year') d.setFullYear(d.getFullYear() + count);
|
|
return d;
|
|
}
|
|
|
|
async getCoffeeProduct(id) {
|
|
const [rows] = await pool.query(
|
|
`SELECT id, price, currency, billing_interval, interval_count, state AS is_active, pack_group FROM coffee_table WHERE id = ?`,
|
|
[id],
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
// Helper: normalize email
|
|
normalizeEmail(email) {
|
|
return typeof email === 'string' ? email.trim().toLowerCase() : null;
|
|
}
|
|
|
|
// NEW: single bundle subscribe using items array (12 packs, 120 capsules)
|
|
async subscribeOrder({
|
|
items,
|
|
billingInterval,
|
|
intervalCount,
|
|
isAutoRenew,
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
street,
|
|
postalCode,
|
|
city,
|
|
country,
|
|
frequency,
|
|
startDate,
|
|
actorUser,
|
|
referredBy, // NEW: referred_by field
|
|
}) {
|
|
console.log('[SUBSCRIBE ORDER] Start processing subscription order');
|
|
console.log('[SUBSCRIBE ORDER] Payload:', {
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
street,
|
|
postalCode,
|
|
city,
|
|
country,
|
|
frequency,
|
|
startDate,
|
|
});
|
|
|
|
const normalizedEmail = this.normalizeEmail(email);
|
|
|
|
if (!Array.isArray(items) || items.length === 0) throw new Error('items must be a non-empty array');
|
|
|
|
let totalPacks = 0;
|
|
let totalPrice = 0;
|
|
const breakdown = [];
|
|
|
|
for (const item of items) {
|
|
const coffeeId = item?.coffeeId;
|
|
const packs = Number(item?.quantity ?? 0);
|
|
if (!coffeeId) throw new Error('coffeeId is required for each item');
|
|
if (!Number.isFinite(packs) || packs <= 0) throw new Error('quantity must be a positive integer per item');
|
|
|
|
const product = await this.getCoffeeProduct(coffeeId);
|
|
if (!product || !product.is_active) throw new Error(`Product ${coffeeId} not available`);
|
|
|
|
totalPacks += packs;
|
|
totalPrice += packs * Number(product.price);
|
|
|
|
breakdown.push({
|
|
coffee_table_id: coffeeId,
|
|
packs,
|
|
price_per_pack: Number(product.price),
|
|
currency: product.currency,
|
|
});
|
|
}
|
|
|
|
const now = new Date();
|
|
const startDateObj = startDate ? new Date(startDate) : now;
|
|
const nextBilling = this.addInterval(startDateObj, billingInterval || 'month', intervalCount || 1);
|
|
|
|
const snapshot = {
|
|
status: 'active',
|
|
started_at: startDateObj,
|
|
next_billing_at: nextBilling,
|
|
billing_interval: billingInterval || 'month',
|
|
interval_count: intervalCount || 1,
|
|
price: Number(totalPrice.toFixed(2)),
|
|
currency: breakdown[0]?.currency || 'EUR',
|
|
is_auto_renew: isAutoRenew !== false,
|
|
actor_user_id: actorUser?.id,
|
|
details: { origin: 'subscribe_order', total_packs: totalPacks },
|
|
pack_breakdown: breakdown,
|
|
first_name: firstName,
|
|
last_name: lastName,
|
|
email: normalizedEmail,
|
|
street,
|
|
postal_code: postalCode,
|
|
city,
|
|
country,
|
|
frequency,
|
|
referred_by: referredBy || null, // Pass referred_by to snapshot
|
|
};
|
|
|
|
return this.repo.createAbonement(referredBy, snapshot); // Pass referredBy to repository
|
|
}
|
|
|
|
async subscribe({
|
|
userId,
|
|
coffeeId,
|
|
billingInterval,
|
|
intervalCount,
|
|
isAutoRenew,
|
|
targetUserId,
|
|
recipientName,
|
|
recipientEmail,
|
|
recipientNotes,
|
|
actorUser,
|
|
referredBy, // NEW: referred_by field
|
|
}) {
|
|
console.log('[SUBSCRIBE] Start processing single subscription'); // NEW
|
|
console.log('[SUBSCRIBE] Recipient email:', recipientEmail); // NEW
|
|
|
|
const normalizedRecipientEmail = this.normalizeEmail(recipientEmail);
|
|
console.log('[SUBSCRIBE] Normalized recipient email:', normalizedRecipientEmail); // NEW
|
|
|
|
if (coffeeId === undefined || coffeeId === null) throw new Error('coffeeId is required');
|
|
|
|
const hasRecipientFields = recipientName || normalizedRecipientEmail || recipientNotes;
|
|
if (targetUserId && hasRecipientFields) throw new Error('Provide either target_user_id or recipient fields, not both');
|
|
if (hasRecipientFields && !normalizedRecipientEmail) throw new Error('recipient_email is required when subscribing for another person');
|
|
|
|
const safeUserId = userId ?? null;
|
|
const isForMe = !targetUserId && !hasRecipientFields;
|
|
const ownerUserId = targetUserId ?? (hasRecipientFields ? null : safeUserId);
|
|
const purchaserUserId = isForMe ? null : actorUser?.id || null;
|
|
|
|
const recipientMeta = targetUserId
|
|
? { target_user_id: targetUserId }
|
|
: hasRecipientFields
|
|
? { recipient_name: recipientName, recipient_email: normalizedRecipientEmail, recipient_notes: recipientNotes }
|
|
: null;
|
|
|
|
const product = await this.getCoffeeProduct(coffeeId);
|
|
if (!product || !product.is_active) throw new Error('Product not available');
|
|
|
|
const canonicalPackGroup = product.pack_group || `product:${coffeeId}`;
|
|
const now = new Date();
|
|
const nextBilling = this.addInterval(
|
|
now,
|
|
billingInterval || product.billing_interval,
|
|
intervalCount || product.interval_count || 1,
|
|
);
|
|
|
|
const details = { origin: 'subscribe', pack_group: canonicalPackGroup };
|
|
if (recipientMeta) details.recipient = recipientMeta;
|
|
|
|
const snapshot = {
|
|
status: 'active',
|
|
pack_group: canonicalPackGroup,
|
|
started_at: now,
|
|
next_billing_at: nextBilling,
|
|
billing_interval: billingInterval || product.billing_interval,
|
|
interval_count: intervalCount || product.interval_count || 1,
|
|
price: product.price,
|
|
currency: product.currency,
|
|
is_auto_renew: isAutoRenew !== false,
|
|
actor_user_id: actorUser?.id,
|
|
notes: recipientMeta && recipientMeta.recipient_name ? recipientMeta.recipient_name : undefined,
|
|
recipient_email: normalizedRecipientEmail || null, // CHANGED
|
|
details,
|
|
recipient: recipientMeta || undefined,
|
|
purchaser_user_id: purchaserUserId, // NEW
|
|
referred_by: referredBy || null, // Pass referred_by to snapshot
|
|
};
|
|
|
|
const existing = await this.repo.findActiveOrPausedByUserAndProduct(ownerUserId ?? null, canonicalPackGroup);
|
|
const abonement = existing
|
|
? await this.repo.updateExistingAbonementForSubscribe(existing.id, snapshot)
|
|
: await this.repo.createAbonement(ownerUserId ?? null, snapshot, referredBy); // Pass referredBy to repository
|
|
|
|
console.log('[SUBSCRIBE] Single subscription completed successfully'); // NEW
|
|
return abonement;
|
|
}
|
|
|
|
// NEW: authorization helper
|
|
canManageAbonement(abon, actorUser) {
|
|
if (this.isAdmin(actorUser)) return true;
|
|
const actorId = actorUser?.id;
|
|
if (!actorId) return false;
|
|
if (abon.user_id && abon.user_id === actorId) return true;
|
|
if (!abon.user_id && abon.purchaser_user_id && abon.purchaser_user_id === actorId) return true;
|
|
return false;
|
|
}
|
|
|
|
async pause({ abonementId, actorUser }) {
|
|
const abon = await this.repo.getAbonementById(abonementId);
|
|
if (!abon) throw new Error('Not found');
|
|
if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW
|
|
if (!abon.isActive) throw new Error('Only active abonements can be paused');
|
|
return this.repo.transitionStatus(abonementId, 'paused', {
|
|
event_type: 'paused',
|
|
actor_user_id: actorUser?.id,
|
|
details: { pack_group: abon.pack_group },
|
|
});
|
|
}
|
|
|
|
async resume({ abonementId, actorUser }) {
|
|
const abon = await this.repo.getAbonementById(abonementId);
|
|
if (!abon) throw new Error('Not found');
|
|
if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW
|
|
if (!abon.isPaused) throw new Error('Only paused abonements can be resumed');
|
|
return this.repo.transitionStatus(abonementId, 'active', {
|
|
event_type: 'resumed',
|
|
actor_user_id: actorUser?.id,
|
|
details: { pack_group: abon.pack_group },
|
|
});
|
|
}
|
|
|
|
async cancel({ abonementId, actorUser }) {
|
|
const abon = await this.repo.getAbonementById(abonementId);
|
|
if (!abon) throw new Error('Not found');
|
|
if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); // NEW
|
|
if (abon.isCanceled) return abon;
|
|
return this.repo.transitionStatus(abonementId, 'canceled', {
|
|
event_type: 'canceled',
|
|
actor_user_id: actorUser?.id,
|
|
ended_at: new Date(),
|
|
details: { pack_group: abon.pack_group },
|
|
});
|
|
}
|
|
|
|
async renew({ abonementId, actorUser, invoiceId }) {
|
|
const abon = await this.repo.getAbonementById(abonementId);
|
|
if (!abon) throw new Error('Not found');
|
|
if (!abon.isActive) throw new Error('Only active abonements can be renewed');
|
|
const next = this.addInterval(new Date(abon.next_billing_at || new Date()), abon.billing_interval, abon.interval_count);
|
|
return this.repo.transitionBilling(abonementId, next, {
|
|
event_type: 'renewed',
|
|
actor_user_id: actorUser?.id || null,
|
|
details: { pack_group: abon.pack_group, ...(invoiceId ? { invoiceId } : {}) },
|
|
});
|
|
}
|
|
|
|
async getMyAbonements({ userId }) {
|
|
return this.repo.listByUser(userId);
|
|
}
|
|
|
|
async getHistory({ abonementId }) {
|
|
return this.repo.listHistory(abonementId);
|
|
}
|
|
|
|
async linkGiftFlagsToUser(email, userId) {
|
|
const normalizedEmail = this.normalizeEmail(email); // NEW
|
|
const pending = await this.repo.findPendingNoUserAboMailsByEmail(normalizedEmail);
|
|
for (const row of pending) {
|
|
await this.repo.linkAbonementToUser(row.abonement_id, userId);
|
|
await this.repo.appendHistory(
|
|
row.abonement_id,
|
|
'gift_linked',
|
|
userId,
|
|
{ email, pack_group: (await this.repo.getAbonementById(row.abonement_id))?.pack_group || null }
|
|
);
|
|
await this.repo.markNoUserAboMailLinked(row.id); // CHANGED
|
|
}
|
|
return pending.length;
|
|
}
|
|
|
|
async adminList({ status }) {
|
|
const [rows] = await pool.query(
|
|
`SELECT * FROM coffee_abonements ${status ? 'WHERE status = ?' : ''} ORDER BY created_at DESC LIMIT 200`,
|
|
status ? [status] : [],
|
|
);
|
|
return rows;
|
|
}
|
|
|
|
async adminRenew({ abonementId, actorUser }) {
|
|
if (!this.isAdmin(actorUser)) throw new Error('Forbidden');
|
|
return this.renew({ abonementId, actorUser });
|
|
}
|
|
|
|
async getReferredSubscriptions({ userId, email }) {
|
|
if (!userId || !email) throw new Error('User ID and email are required');
|
|
const rows = await this.repo.findByReferredByAndEmail(userId, email);
|
|
|
|
// Collect distinct coffee_table_ids from pack_breakdown
|
|
const idsSet = new Set();
|
|
for (const r of rows) {
|
|
const breakdown = Array.isArray(r.pack_breakdown) ? r.pack_breakdown : [];
|
|
for (const item of breakdown) {
|
|
const id = item?.coffee_table_id;
|
|
if (id !== undefined && id !== null) idsSet.add(Number(id));
|
|
}
|
|
}
|
|
const ids = Array.from(idsSet);
|
|
let nameMap = {};
|
|
if (ids.length) {
|
|
const [nameRows] = await pool.query(
|
|
`SELECT id, title FROM coffee_table WHERE id IN (${ids.map(() => '?').join(',')})`,
|
|
ids
|
|
);
|
|
nameMap = (nameRows || []).reduce((acc, row) => {
|
|
acc[Number(row.id)] = row.title; // CHANGED: use title
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
// Attach coffee_name to each pack_breakdown item
|
|
const enriched = rows.map(r => {
|
|
const breakdown = Array.isArray(r.pack_breakdown) ? r.pack_breakdown : [];
|
|
const withNames = breakdown.map(item => ({
|
|
...item,
|
|
coffee_name: nameMap[Number(item.coffee_table_id)] || null,
|
|
}));
|
|
// Return a plain object with enriched breakdown
|
|
return {
|
|
...r,
|
|
pack_breakdown: withNames,
|
|
};
|
|
});
|
|
|
|
return enriched;
|
|
}
|
|
}
|
|
|
|
module.exports = AbonemmentService; |