const pool = require('../../database/database'); const AbonemmentRepository = require('../../repositories/abonemments/AbonemmentRepository'); const InvoiceService = require('../invoice/InvoiceService'); // NEW const UnitOfWork = require('../../database/UnitOfWork'); const ReferralService = require('../referral/ReferralService'); const ReferralTokenRepository = require('../../repositories/referral/ReferralTokenRepository'); const MailService = require('../email/MailService'); class AbonemmentService { constructor() { this.repo = new AbonemmentRepository(); this.invoiceService = new InvoiceService(); // NEW } 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, title, 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, isForSelf, recipientName, recipientEmail, recipientNotes, 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] Actor user:', actorUser ? { id: actorUser.id, role: actorUser.role, email: actorUser.email } : actorUser); console.log('[SUBSCRIBE ORDER] Payload:', { firstName, lastName, email, street, postalCode, city, country, frequency, startDate, }); const normalizedEmail = this.normalizeEmail(email); const normalizedRecipientEmail = this.normalizeEmail(recipientEmail); const forSelf = isForSelf !== false && !normalizedRecipientEmail; if (!forSelf && !normalizedRecipientEmail) { throw new Error('recipient_email is required when subscription is for another person'); } 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, coffee_title: product.title || null, 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 effectiveRecipientName = recipientName || `${firstName || ''} ${lastName || ''}`.trim() || null; const effectiveEmail = forSelf ? normalizedEmail : normalizedRecipientEmail; 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, is_for_self: forSelf, recipient_name: forSelf ? null : effectiveRecipientName, recipient_notes: forSelf ? null : (recipientNotes || null) }, pack_breakdown: breakdown, first_name: forSelf ? firstName : (effectiveRecipientName || firstName), last_name: forSelf ? lastName : null, email: effectiveEmail, street, postal_code: postalCode, city, country, frequency, referred_by: referredBy || (forSelf ? (actorUser?.id ?? null) : null), user_id: forSelf ? (actorUser?.id ?? null) : null, purchaser_user_id: actorUser?.id ?? null, // NEW: also store purchaser }; console.log('[SUBSCRIBE ORDER] Snapshot user linking:', { actor_user_id: snapshot.actor_user_id, user_id: snapshot.user_id, purchaser_user_id: snapshot.purchaser_user_id, referred_by: snapshot.referred_by, }); const abonement = await this.repo.createAbonement(referredBy, snapshot); // Pass referredBy to repository console.log('[SUBSCRIBE ORDER] Created abonement:', { id: abonement?.id, user_id: abonement?.user_id, purchaser_user_id: abonement?.purchaser_user_id, status: abonement?.status, pack_group: abonement?.pack_group, }); // NEW: issue invoice for first period and append history try { const invoice = await this.invoiceService.issueForAbonement( abonement, snapshot.started_at, snapshot.next_billing_at, { actorUserId: actorUser?.id || null, userType: actorUser?.user_type || actorUser?.userType || 'personal', lang: actorUser?.lang || actorUser?.language || 'en' } ); console.log('[SUBSCRIBE ORDER] Issued invoice:', { id: invoice?.id, user_id: invoice?.user_id, total_gross: invoice?.total_gross, vat_rate: invoice?.vat_rate, }); await this.repo.appendHistory( abonement.id, 'invoice_issued', actorUser?.id || null, { pack_group: abonement.pack_group, invoiceId: invoice.id }, new Date() ); } catch (e) { console.error('[SUBSCRIBE ORDER] Invoice issue failed:', e); // intentionally not throwing to avoid blocking subscription; adjust if you want transactional consistency } if (!forSelf && effectiveEmail) { const existingUser = await this.findUserByEmail(effectiveEmail); if (!existingUser) { await this.repo.upsertNoUserAboMail(effectiveEmail, abonement.id, actorUser?.id || null); const referralLink = await this.getOrCreateReferralLink(actorUser?.id || null); if (referralLink) { try { await MailService.sendSubscriptionInvitationEmail({ email: effectiveEmail, inviterName: actorUser?.email || 'ProfitPlanet user', referralLink, lang: actorUser?.lang || actorUser?.language || 'en' }); } catch (mailError) { console.error('[SUBSCRIBE ORDER] Invitation email failed:', mailError); } } } } return abonement; } async findUserByEmail(email) { const normalized = this.normalizeEmail(email); if (!normalized) return null; const [rows] = await pool.query( `SELECT id, email FROM users WHERE email = ? LIMIT 1`, [normalized] ); return rows && rows[0] ? rows[0] : null; } async getOrCreateReferralLink(userId) { if (!userId) return null; const unitOfWork = new UnitOfWork(); await unitOfWork.start(); try { const repo = new ReferralTokenRepository(unitOfWork); const tokens = await repo.getTokensByUser(userId); const now = Date.now(); const active = (tokens || []).find((t) => { const statusOk = t.status === 'active'; const remaining = Number(t.uses_remaining); const unlimited = Number(t.max_uses) === -1 || remaining === -1; const remainingOk = unlimited || remaining > 0; const exp = t.expires_at ? new Date(t.expires_at).getTime() : NaN; const expiryOk = Number.isNaN(exp) ? true : exp > now; return statusOk && remainingOk && expiryOk; }); let tokenValue; if (active?.token) { tokenValue = active.token; } else { const created = await ReferralService.createReferralToken({ userId, expiresInDays: 7, maxUses: 1, unitOfWork, }); tokenValue = created?.token; } await unitOfWork.commit(); if (!tokenValue) return null; const base = (process.env.FRONTEND_URL || 'https://profit-planet.partners').replace(/\/$/, ''); return `${base}/register?ref=${tokenValue}`; } catch (error) { await unitOfWork.rollback(error); console.error('[ABONEMENT] getOrCreateReferralLink failed:', error); return null; } } 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] Actor user:', actorUser ? { id: actorUser.id, role: actorUser.role, email: actorUser.email } : actorUser); console.log('[SUBSCRIBE] Incoming userId:', userId, 'targetUserId:', targetUserId); // 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; console.log('[SUBSCRIBE] Resolved ownership:', { isForMe, ownerUserId, purchaserUserId }); // NEW 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, pack_breakdown: [{ coffee_table_id: product.id, coffee_title: product.title || null, packs: 1, price_per_pack: Number(product.price), currency: product.currency, }], purchaser_user_id, // NEW user_id: ownerUserId ?? null, // NEW: persist owner referred_by: referredBy || null, // Pass referred_by to snapshot }; console.log('[SUBSCRIBE] Snapshot user linking:', { actor_user_id: snapshot.actor_user_id, user_id: snapshot.user_id, purchaser_user_id: snapshot.purchaser_user_id, referred_by: snapshot.referred_by, }); const existing = await this.repo.findActiveOrPausedByUserAndProduct(ownerUserId ?? null, canonicalPackGroup); console.log('[SUBSCRIBE] Existing abonement candidate:', existing ? { id: existing.id, user_id: existing.user_id } : null); // NEW 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] Upserted abonement:', { id: abonement?.id, user_id: abonement?.user_id, purchaser_user_id: abonement?.purchaser_user_id, status: abonement?.status, pack_group: abonement?.pack_group, }); // NEW: issue invoice for first period and append history try { const invoice = await this.invoiceService.issueForAbonement( abonement, snapshot.started_at, snapshot.next_billing_at, { actorUserId: actorUser?.id || null, userType: actorUser?.user_type || actorUser?.userType || 'personal', lang: actorUser?.lang || actorUser?.language || 'en' } ); console.log('[SUBSCRIBE] Issued invoice:', { id: invoice?.id, user_id: invoice?.user_id, total_gross: invoice?.total_gross, vat_rate: invoice?.vat_rate, }); await this.repo.appendHistory( abonement.id, 'invoice_issued', actorUser?.id || null, { pack_group: abonement.pack_group, invoiceId: invoice.id }, new Date() ); } catch (e) { console.error('[SUBSCRIBE] Invoice issue failed:', e); } 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 updateContent({ abonementId, actorUser, items }) { const abon = await this.repo.getAbonementById(abonementId); if (!abon) throw new Error('Not found'); if (!this.canManageAbonement(abon, actorUser)) throw new Error('Forbidden'); if (!['active', 'paused'].includes(abon.status)) { throw new Error('Only active or paused abonements can be updated'); } 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, coffee_title: product.title || null, packs, price_per_pack: Number(product.price), currency: product.currency, }); } if (totalPacks !== 6 && totalPacks !== 12) { throw new Error('Order must contain exactly 6 packs (60 capsules) or 12 packs (120 capsules).'); } const previousPacks = Array.isArray(abon.pack_breakdown) ? abon.pack_breakdown.reduce((sum, item) => sum + Number(item?.packs || item?.quantity || 0), 0) : null; return this.repo.transitionContent(abonementId, { pack_breakdown: breakdown, price: Number(totalPrice.toFixed(2)), currency: breakdown[0]?.currency || abon.currency || 'EUR', actor_user_id: actorUser?.id || null, event_type: 'content_updated', details: { pack_group: abon.pack_group, previous_total_packs: previousPacks, total_packs: totalPacks, effective_from: 'next_billing_cycle', }, }); } 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); const renewed = await this.repo.transitionBilling(abonementId, next, { event_type: 'renewed', actor_user_id: actorUser?.id || null, details: { pack_group: abon.pack_group, ...(invoiceId ? { invoiceId } : {}) }, }); // NEW: issue invoice for next cycle and attach to history try { const invoice = await this.invoiceService.issueForAbonement( renewed, new Date(abon.next_billing_at || new Date()), next, { actorUserId: actorUser?.id || null, userType: actorUser?.user_type || actorUser?.userType || 'personal', lang: actorUser?.lang || actorUser?.language || 'en' } ); await this.repo.appendHistory( abonementId, 'invoice_issued', actorUser?.id || null, { pack_group: renewed.pack_group, invoiceId: invoice.id }, new Date() ); } catch (e) { console.error('[RENEW] Invoice issue failed:', e); } return renewed; } async getMyAbonements({ userId }) { return this.repo.listByUser(userId); } async getMyAbonementStatus({ userId }) { const list = await this.repo.listByUser(userId); const current = list.find((a) => ['active', 'paused'].includes(a.status)) || list[0] || null; return { hasAbo: Boolean(current), abonement: current, }; } async getInvoicesForAbonement({ 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'); } return this.invoiceService.listByAbonement(abonementId); } 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;