CentralBackend/services/abonemments/AbonemmentService.js
2025-12-13 11:15:20 +01:00

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;