feat: enhance pool management with subscription linking and inflow diagnostics
This commit is contained in:
parent
bf8f94b848
commit
4ce8507858
@ -1,17 +1,29 @@
|
|||||||
const { createPool, listPools, updatePoolState } = require('../../services/pool/PoolService');
|
const { createPool, listPools, updatePoolState, updatePoolSubscription } = require('../../services/pool/PoolService');
|
||||||
const PoolMemberService = require('../../services/pool/PoolMemberService');
|
const PoolMemberService = require('../../services/pool/PoolMemberService');
|
||||||
|
const PoolInflowService = require('../../services/pool/PoolInflowService');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
try {
|
try {
|
||||||
const { pool_name, description, price, pool_type, is_active } = req.body || {};
|
const { pool_name, description, price, price_net, subscription_coffee_id, pool_type, is_active } = req.body || {};
|
||||||
|
const normalizedNetPrice = Number(price_net ?? price);
|
||||||
const actorUserId = req.user && req.user.userId;
|
const actorUserId = req.user && req.user.userId;
|
||||||
if (!pool_name) return res.status(400).json({ success: false, message: 'Pool name is required' });
|
if (!pool_name) return res.status(400).json({ success: false, message: 'Pool name is required' });
|
||||||
if (!price || price < 0) return res.status(400).json({ success: false, message: 'Valid price is required' });
|
if (!Number.isFinite(normalizedNetPrice) || normalizedNetPrice < 0) {
|
||||||
|
return res.status(400).json({ success: false, message: 'Valid net price per capsule is required' });
|
||||||
|
}
|
||||||
if (pool_type && !['coffee', 'other'].includes(pool_type)) {
|
if (pool_type && !['coffee', 'other'].includes(pool_type)) {
|
||||||
return res.status(400).json({ success: false, message: 'Invalid pool_type. Allowed: coffee, other' });
|
return res.status(400).json({ success: false, message: 'Invalid pool_type. Allowed: coffee, other' });
|
||||||
}
|
}
|
||||||
const pool = await createPool({ pool_name, description, price, pool_type, is_active, created_by: actorUserId });
|
const pool = await createPool({
|
||||||
|
pool_name,
|
||||||
|
description,
|
||||||
|
price: Number(normalizedNetPrice.toFixed(2)),
|
||||||
|
subscription_coffee_id,
|
||||||
|
pool_type,
|
||||||
|
is_active,
|
||||||
|
created_by: actorUserId
|
||||||
|
});
|
||||||
return res.status(201).json({ success: true, data: pool });
|
return res.status(201).json({ success: true, data: pool });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e && (e.code === 'ER_DUP_ENTRY' || e.errno === 1062)) {
|
if (e && (e.code === 'ER_DUP_ENTRY' || e.errno === 1062)) {
|
||||||
@ -57,6 +69,28 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateSubscription(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params || {};
|
||||||
|
const { subscription_coffee_id } = req.body || {};
|
||||||
|
const actorUserId = req.user && req.user.userId;
|
||||||
|
if (!id) return res.status(400).json({ success: false, message: 'id is required' });
|
||||||
|
|
||||||
|
const updated = await updatePoolSubscription({
|
||||||
|
id,
|
||||||
|
subscription_coffee_id,
|
||||||
|
actorUserId,
|
||||||
|
});
|
||||||
|
return res.status(200).json({ success: true, data: updated });
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.status === 400) {
|
||||||
|
return res.status(400).json({ success: false, message: e.message });
|
||||||
|
}
|
||||||
|
console.error('[PoolController.updateSubscription]', e);
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async listMembers(req, res) {
|
async listMembers(req, res) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params || {};
|
const { id } = req.params || {};
|
||||||
@ -101,5 +135,24 @@ module.exports = {
|
|||||||
console.error('[PoolController.removeMembers]', e);
|
console.error('[PoolController.removeMembers]', e);
|
||||||
return res.status(500).json({ success: false, message: 'Internal server error' });
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async inflowDiagnostics(req, res) {
|
||||||
|
try {
|
||||||
|
const invoiceId = Number(req.query?.invoiceId);
|
||||||
|
if (!Number.isFinite(invoiceId) || invoiceId <= 0) {
|
||||||
|
return res.status(400).json({ success: false, message: 'invoiceId is required and must be a positive number' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await PoolInflowService.getInvoiceInflowDiagnostics({
|
||||||
|
invoiceId,
|
||||||
|
paidAt: req.query?.paidAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).json({ success: true, data });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[PoolController.inflowDiagnostics]', e);
|
||||||
|
return res.status(500).json({ success: false, message: 'Internal server error' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1013,6 +1013,7 @@ const createDatabase = async () => {
|
|||||||
pool_name VARCHAR(255) NOT NULL,
|
pool_name VARCHAR(255) NOT NULL,
|
||||||
description TEXT NULL,
|
description TEXT NULL,
|
||||||
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
|
||||||
|
subscription_coffee_id BIGINT NULL,
|
||||||
pool_type ENUM('coffee','other') NOT NULL DEFAULT 'other',
|
pool_type ENUM('coffee','other') NOT NULL DEFAULT 'other',
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
@ -1020,14 +1021,30 @@ const createDatabase = async () => {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
CONSTRAINT uq_pools_name UNIQUE (pool_name),
|
CONSTRAINT uq_pools_name UNIQUE (pool_name),
|
||||||
|
CONSTRAINT fk_pools_subscription_coffee FOREIGN KEY (subscription_coffee_id) REFERENCES coffee_table(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
CONSTRAINT fk_pools_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
CONSTRAINT fk_pools_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
CONSTRAINT fk_pools_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
CONSTRAINT fk_pools_updated_by FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
INDEX idx_pools_active (is_active),
|
INDEX idx_pools_active (is_active),
|
||||||
INDEX idx_pools_type (pool_type)
|
INDEX idx_pools_type (pool_type),
|
||||||
|
INDEX idx_pools_subscription_coffee (subscription_coffee_id)
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
console.log('✅ Pools table created/verified');
|
console.log('✅ Pools table created/verified');
|
||||||
|
|
||||||
|
// Backward-compatible migration for existing pools table
|
||||||
|
await addColumnIfMissing(connection, 'pools', 'subscription_coffee_id', `BIGINT NULL`);
|
||||||
|
await ensureIndex(connection, 'pools', 'idx_pools_subscription_coffee', '`subscription_coffee_id`');
|
||||||
|
await addForeignKeyIfMissing(
|
||||||
|
connection,
|
||||||
|
'pools',
|
||||||
|
'fk_pools_subscription_coffee',
|
||||||
|
`
|
||||||
|
ALTER TABLE pools
|
||||||
|
ADD CONSTRAINT fk_pools_subscription_coffee FOREIGN KEY (subscription_coffee_id)
|
||||||
|
REFERENCES coffee_table(id) ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS pool_members (
|
CREATE TABLE IF NOT EXISTS pool_members (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@ -1046,6 +1063,70 @@ const createDatabase = async () => {
|
|||||||
`);
|
`);
|
||||||
console.log('✅ pool_members table created/verified');
|
console.log('✅ pool_members table created/verified');
|
||||||
|
|
||||||
|
// Track money inflow into pools from subscriptions/invoices
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS pool_inflows (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
pool_id INT NOT NULL,
|
||||||
|
invoice_id BIGINT NULL,
|
||||||
|
abonement_id BIGINT NOT NULL,
|
||||||
|
coffee_table_id BIGINT NOT NULL,
|
||||||
|
event_type ENUM('invoice_paid','subscription_created','renewal_paid','manual_adjustment') NOT NULL DEFAULT 'invoice_paid',
|
||||||
|
capsules_count INT NOT NULL,
|
||||||
|
price_per_capsule_net DECIMAL(10,4) NOT NULL,
|
||||||
|
amount_net DECIMAL(12,2) NOT NULL,
|
||||||
|
currency CHAR(3) NOT NULL DEFAULT 'EUR',
|
||||||
|
details JSON NULL,
|
||||||
|
created_by_user_id INT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_pool_inflows_pool FOREIGN KEY (pool_id) REFERENCES pools(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_pool_inflows_invoice FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_pool_inflows_abon FOREIGN KEY (abonement_id) REFERENCES coffee_abonements(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_pool_inflows_coffee FOREIGN KEY (coffee_table_id) REFERENCES coffee_table(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT fk_pool_inflows_created_by FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT uq_pool_inflow_invoice_event UNIQUE (pool_id, invoice_id, coffee_table_id, event_type),
|
||||||
|
INDEX idx_pool_inflows_pool_created (pool_id, created_at),
|
||||||
|
INDEX idx_pool_inflows_abon (abonement_id),
|
||||||
|
INDEX idx_pool_inflows_invoice (invoice_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
console.log('✅ pool_inflows table created/verified');
|
||||||
|
|
||||||
|
// Backward-compatible migration for existing pool_inflows table
|
||||||
|
await addColumnIfMissing(connection, 'pool_inflows', 'invoice_id', `BIGINT NULL`);
|
||||||
|
await ensureIndex(connection, 'pool_inflows', 'idx_pool_inflows_invoice', '`invoice_id`');
|
||||||
|
await addForeignKeyIfMissing(
|
||||||
|
connection,
|
||||||
|
'pool_inflows',
|
||||||
|
'fk_pool_inflows_invoice',
|
||||||
|
`
|
||||||
|
ALTER TABLE pool_inflows
|
||||||
|
ADD CONSTRAINT fk_pool_inflows_invoice FOREIGN KEY (invoice_id)
|
||||||
|
REFERENCES invoices(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await connection.query(`
|
||||||
|
ALTER TABLE pool_inflows
|
||||||
|
MODIFY COLUMN event_type ENUM('invoice_paid','subscription_created','renewal_paid','manual_adjustment') NOT NULL DEFAULT 'invoice_paid'
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ pool_inflows.event_type enum alignment skipped:', e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await connection.query(`ALTER TABLE pool_inflows DROP INDEX uq_pool_inflow_event`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ old pool inflow unique index drop skipped:', e.message);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await connection.query(`
|
||||||
|
ALTER TABLE pool_inflows
|
||||||
|
ADD CONSTRAINT uq_pool_inflow_invoice_event UNIQUE (pool_id, invoice_id, coffee_table_id, event_type)
|
||||||
|
`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log('ℹ️ new pool inflow unique index creation skipped:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// --- user_matrix_metadata: add matrix_instance_id + alter PK ---
|
// --- user_matrix_metadata: add matrix_instance_id + alter PK ---
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
CREATE TABLE IF NOT EXISTS user_matrix_metadata (
|
CREATE TABLE IF NOT EXISTS user_matrix_metadata (
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
class Pool {
|
class Pool {
|
||||||
constructor({ id = null, pool_name, description = null, price = 0.00, pool_type = 'other', is_active = true, created_by = null, updated_by = null, created_at = null, updated_at = null, members_count = 0 }) {
|
constructor({ id = null, pool_name, description = null, price = 0.00, subscription_coffee_id = null, subscription_title = null, pool_type = 'other', is_active = true, created_by = null, updated_by = null, created_at = null, updated_at = null, members_count = 0 }) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.pool_name = pool_name;
|
this.pool_name = pool_name;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
this.price = price;
|
this.price = price;
|
||||||
|
this.price_net = Number(price || 0);
|
||||||
|
this.price_per_capsule_net = Number(price || 0);
|
||||||
|
this.subscription_coffee_id = subscription_coffee_id == null ? null : Number(subscription_coffee_id);
|
||||||
|
this.subscription_title = subscription_title || null;
|
||||||
this.pool_type = pool_type;
|
this.pool_type = pool_type;
|
||||||
this.is_active = is_active;
|
this.is_active = is_active;
|
||||||
this.created_by = created_by;
|
this.created_by = created_by;
|
||||||
|
|||||||
@ -5,16 +5,16 @@ class PoolRepository {
|
|||||||
this.uow = uow;
|
this.uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create({ pool_name, description = null, price = 0.00, pool_type = 'other', is_active = true, created_by = null }) {
|
async create({ pool_name, description = null, price = 0.00, subscription_coffee_id = null, pool_type = 'other', is_active = true, created_by = null }) {
|
||||||
const conn = this.uow.connection;
|
const conn = this.uow.connection;
|
||||||
try {
|
try {
|
||||||
console.info('PoolRepository.create:start', { pool_name, pool_type, is_active, price, created_by });
|
console.info('PoolRepository.create:start', { pool_name, pool_type, is_active, price, subscription_coffee_id, created_by });
|
||||||
const sql = `INSERT INTO pools (pool_name, description, price, pool_type, is_active, created_by)
|
const sql = `INSERT INTO pools (pool_name, description, price, subscription_coffee_id, pool_type, is_active, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`;
|
VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
||||||
const params = [pool_name, description, price, pool_type, is_active, created_by];
|
const params = [pool_name, description, price, subscription_coffee_id, pool_type, is_active, created_by];
|
||||||
const [res] = await conn.execute(sql, params);
|
const [res] = await conn.execute(sql, params);
|
||||||
console.info('PoolRepository.create:success', { insertId: res?.insertId });
|
console.info('PoolRepository.create:success', { insertId: res?.insertId });
|
||||||
return new Pool({ id: res.insertId, pool_name, description, price, pool_type, is_active, created_by });
|
return new Pool({ id: res.insertId, pool_name, description, price, subscription_coffee_id, pool_type, is_active, created_by });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('PoolRepository.create:error', { code: err?.code, errno: err?.errno, sqlMessage: err?.sqlMessage, message: err?.message });
|
console.error('PoolRepository.create:error', { code: err?.code, errno: err?.errno, sqlMessage: err?.sqlMessage, message: err?.message });
|
||||||
const e = new Error('Failed to create pool');
|
const e = new Error('Failed to create pool');
|
||||||
@ -29,10 +29,11 @@ class PoolRepository {
|
|||||||
try {
|
try {
|
||||||
console.info('PoolRepository.findAll:start');
|
console.info('PoolRepository.findAll:start');
|
||||||
const sql = `SELECT
|
const sql = `SELECT
|
||||||
p.id, p.pool_name, p.description, p.price, p.pool_type, p.is_active,
|
p.id, p.pool_name, p.description, p.price, p.subscription_coffee_id, c.title AS subscription_title, p.pool_type, p.is_active,
|
||||||
p.created_by, p.updated_by, p.created_at, p.updated_at,
|
p.created_by, p.updated_by, p.created_at, p.updated_at,
|
||||||
COUNT(pm.user_id) AS members_count
|
COUNT(pm.user_id) AS members_count
|
||||||
FROM pools p
|
FROM pools p
|
||||||
|
LEFT JOIN coffee_table c ON c.id = p.subscription_coffee_id
|
||||||
LEFT JOIN pool_members pm ON pm.pool_id = p.id
|
LEFT JOIN pool_members pm ON pm.pool_id = p.id
|
||||||
GROUP BY p.id
|
GROUP BY p.id
|
||||||
ORDER BY p.created_at DESC`;
|
ORDER BY p.created_at DESC`;
|
||||||
@ -66,7 +67,10 @@ class PoolRepository {
|
|||||||
[is_active, updated_by, id]
|
[is_active, updated_by, id]
|
||||||
);
|
);
|
||||||
const [updated] = await conn.execute(
|
const [updated] = await conn.execute(
|
||||||
`SELECT id, pool_name, description, price, pool_type, is_active, created_by, updated_by, created_at, updated_at FROM pools WHERE id = ?`,
|
`SELECT p.id, p.pool_name, p.description, p.price, p.subscription_coffee_id, c.title AS subscription_title, p.pool_type, p.is_active, p.created_by, p.updated_by, p.created_at, p.updated_at
|
||||||
|
FROM pools p
|
||||||
|
LEFT JOIN coffee_table c ON c.id = p.subscription_coffee_id
|
||||||
|
WHERE p.id = ?`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
console.info('PoolRepository.updateActive:success', { id, is_active });
|
console.info('PoolRepository.updateActive:success', { id, is_active });
|
||||||
@ -82,6 +86,43 @@ class PoolRepository {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSubscriptionLink(id, subscription_coffee_id = null, updated_by = null) {
|
||||||
|
const conn = this.uow.connection;
|
||||||
|
try {
|
||||||
|
console.info('PoolRepository.updateSubscriptionLink:start', { id, subscription_coffee_id, updated_by });
|
||||||
|
const [rows] = await conn.execute(`SELECT id FROM pools WHERE id = ?`, [id]);
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
const err = new Error('Pool not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
`UPDATE pools SET subscription_coffee_id = ?, updated_by = ?, updated_at = NOW() WHERE id = ?`,
|
||||||
|
[subscription_coffee_id, updated_by, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [updated] = await conn.execute(
|
||||||
|
`SELECT p.id, p.pool_name, p.description, p.price, p.subscription_coffee_id, c.title AS subscription_title, p.pool_type, p.is_active, p.created_by, p.updated_by, p.created_at, p.updated_at
|
||||||
|
FROM pools p
|
||||||
|
LEFT JOIN coffee_table c ON c.id = p.subscription_coffee_id
|
||||||
|
WHERE p.id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Pool(updated[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('PoolRepository.updateSubscriptionLink:error', { id, subscription_coffee_id, code: err?.code, message: err?.message });
|
||||||
|
if (!err.status) {
|
||||||
|
const e = new Error('Failed to update pool subscription link');
|
||||||
|
e.status = 500;
|
||||||
|
e.cause = err;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = PoolRepository;
|
module.exports = PoolRepository;
|
||||||
@ -134,6 +134,8 @@ router.post('/admin/matrix/add-user', authMiddleware, adminOnly, MatrixControlle
|
|||||||
router.get('/admin/pools', authMiddleware, adminOnly, PoolController.list);
|
router.get('/admin/pools', authMiddleware, adminOnly, PoolController.list);
|
||||||
// NEW: Admin list pool members
|
// NEW: Admin list pool members
|
||||||
router.get('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.listMembers);
|
router.get('/admin/pools/:id/members', authMiddleware, adminOnly, PoolController.listMembers);
|
||||||
|
// NEW: Admin diagnose pool inflow for invoice
|
||||||
|
router.get('/admin/pools/inflow-diagnostics', authMiddleware, adminOnly, PoolController.inflowDiagnostics);
|
||||||
|
|
||||||
// NEW: User matrices list and per-instance overview
|
// NEW: User matrices list and per-instance overview
|
||||||
router.get('/matrix/me/list', authMiddleware, MatrixController.listMyMatrices);
|
router.get('/matrix/me/list', authMiddleware, MatrixController.listMyMatrices);
|
||||||
|
|||||||
@ -40,6 +40,8 @@ router.patch('/admin/update-user-status/:id', authMiddleware, adminOnly, AdminUs
|
|||||||
router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
|
router.patch('/admin/coffee/:id/state', authMiddleware, adminOnly, CoffeeController.setState);
|
||||||
// NEW: Admin pool active status update
|
// NEW: Admin pool active status update
|
||||||
router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolController.updateActive);
|
router.patch('/admin/pools/:id/active', authMiddleware, adminOnly, PoolController.updateActive);
|
||||||
|
// NEW: Admin update pool linked subscription
|
||||||
|
router.patch('/admin/pools/:id/subscription', authMiddleware, adminOnly, PoolController.updateSubscription);
|
||||||
// NEW: deactivate a matrix instance (admin-only)
|
// NEW: deactivate a matrix instance (admin-only)
|
||||||
router.patch('/admin/matrix/:id/deactivate', authMiddleware, adminOnly, MatrixController.deactivate);
|
router.patch('/admin/matrix/:id/deactivate', authMiddleware, adminOnly, MatrixController.deactivate);
|
||||||
// NEW: activate a matrix instance (admin-only)
|
// NEW: activate a matrix instance (admin-only)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
|
const InvoiceRepository = require('../../repositories/invoice/InvoiceRepository');
|
||||||
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
|
const UnitOfWork = require('../../database/UnitOfWork'); // NEW
|
||||||
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
|
const TaxRepository = require('../../repositories/tax/taxRepository'); // NEW
|
||||||
|
const PoolInflowService = require('../pool/PoolInflowService');
|
||||||
|
|
||||||
class InvoiceService {
|
class InvoiceService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -125,7 +126,23 @@ class InvoiceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
|
async markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at = new Date(), details } = {}) {
|
||||||
return this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
|
const paidInvoice = await this.repo.markPaid(invoiceId, { payment_method, transaction_id, amount, paid_at, details });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const inflowResult = await PoolInflowService.bookForPaidInvoice({
|
||||||
|
invoiceId: paidInvoice?.id,
|
||||||
|
paidAt: paid_at,
|
||||||
|
actorUserId: null,
|
||||||
|
});
|
||||||
|
console.log('[INVOICE PAID] Pool inflow booking result:', {
|
||||||
|
invoiceId: paidInvoice?.id,
|
||||||
|
...inflowResult,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[INVOICE PAID] Pool inflow booking failed:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paidInvoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
|
async listMine(userId, { status, limit = 50, offset = 0 } = {}) {
|
||||||
|
|||||||
226
services/pool/PoolInflowService.js
Normal file
226
services/pool/PoolInflowService.js
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
const db = require('../../database/database');
|
||||||
|
|
||||||
|
function toTwo(value) {
|
||||||
|
return Number(Number(value || 0).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFour(value) {
|
||||||
|
return Number(Number(value || 0).toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
class PoolInflowService {
|
||||||
|
async analyzePaidInvoice({ invoiceId, paidAt }) {
|
||||||
|
const normalizedInvoiceId = Number(invoiceId);
|
||||||
|
if (!Number.isFinite(normalizedInvoiceId) || normalizedInvoiceId <= 0) {
|
||||||
|
return { ok: false, reason: 'invalid_invoice_id' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [invoiceRows] = await db.query(
|
||||||
|
`SELECT id, source_type, source_id, currency, status, context
|
||||||
|
FROM invoices
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[normalizedInvoiceId]
|
||||||
|
);
|
||||||
|
const invoice = invoiceRows?.[0];
|
||||||
|
if (!invoice) return { ok: false, reason: 'invoice_not_found' };
|
||||||
|
if (String(invoice.status) !== 'paid') return { ok: false, reason: 'invoice_not_paid', invoice };
|
||||||
|
if (String(invoice.source_type) !== 'subscription') return { ok: false, reason: 'unsupported_source_type', invoice };
|
||||||
|
|
||||||
|
const abonementId = Number(invoice.source_id);
|
||||||
|
if (!Number.isFinite(abonementId) || abonementId <= 0) {
|
||||||
|
return { ok: false, reason: 'missing_abonement_relation', invoice };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paidAtCandidate = paidAt ? new Date(paidAt) : new Date();
|
||||||
|
const paidAtDate = Number.isFinite(paidAtCandidate.getTime()) ? paidAtCandidate : new Date();
|
||||||
|
|
||||||
|
let context = {};
|
||||||
|
try {
|
||||||
|
context = invoice.context && typeof invoice.context === 'string' ? JSON.parse(invoice.context) : (invoice.context || {});
|
||||||
|
} catch (_) {
|
||||||
|
context = {};
|
||||||
|
}
|
||||||
|
const periodStart = context?.period_start ? new Date(context.period_start) : null;
|
||||||
|
if (periodStart && Number.isFinite(periodStart.getTime()) && paidAtDate < periodStart) {
|
||||||
|
return { ok: false, reason: 'paid_before_period_start', invoice, abonementId, paidAtDate, periodStart };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [abonRows] = await db.query(
|
||||||
|
`SELECT id, pack_breakdown, currency
|
||||||
|
FROM coffee_abonements
|
||||||
|
WHERE id = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[abonementId]
|
||||||
|
);
|
||||||
|
const abonement = abonRows?.[0];
|
||||||
|
if (!abonement) return { ok: false, reason: 'abonement_not_found', invoice, abonementId };
|
||||||
|
|
||||||
|
const breakdownRaw = abonement.pack_breakdown;
|
||||||
|
let breakdown = [];
|
||||||
|
try {
|
||||||
|
breakdown = breakdownRaw
|
||||||
|
? (typeof breakdownRaw === 'string' ? JSON.parse(breakdownRaw) : breakdownRaw)
|
||||||
|
: [];
|
||||||
|
} catch (_) {
|
||||||
|
breakdown = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedLines = Array.isArray(breakdown)
|
||||||
|
? breakdown
|
||||||
|
.map((line) => ({
|
||||||
|
coffeeId: Number(line?.coffee_table_id),
|
||||||
|
capsulesCount: Number(line?.packs || 0) * 10,
|
||||||
|
}))
|
||||||
|
.filter((line) => Number.isFinite(line.coffeeId) && line.coffeeId > 0 && Number.isFinite(line.capsulesCount) && line.capsulesCount > 0)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!normalizedLines.length) return { ok: false, reason: 'no_breakdown_lines', invoice, abonementId };
|
||||||
|
|
||||||
|
const byCoffee = new Map();
|
||||||
|
for (const line of normalizedLines) {
|
||||||
|
byCoffee.set(line.coffeeId, (byCoffee.get(line.coffeeId) || 0) + line.capsulesCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coffeeIds = Array.from(byCoffee.keys());
|
||||||
|
const placeholders = coffeeIds.map(() => '?').join(',');
|
||||||
|
const [pools] = await db.query(
|
||||||
|
`SELECT id, pool_name, subscription_coffee_id, price
|
||||||
|
FROM pools
|
||||||
|
WHERE is_active = 1 AND subscription_coffee_id IN (${placeholders})`,
|
||||||
|
coffeeIds
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!Array.isArray(pools) || pools.length === 0) {
|
||||||
|
return { ok: false, reason: 'no_linked_pools', invoice, abonementId, normalizedLines };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: 'ok',
|
||||||
|
invoice,
|
||||||
|
abonementId,
|
||||||
|
paidAtDate,
|
||||||
|
byCoffee,
|
||||||
|
pools,
|
||||||
|
normalizedLines,
|
||||||
|
currency: invoice.currency || abonement.currency || 'EUR',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async bookForPaidInvoice({ invoiceId, paidAt, actorUserId = null }) {
|
||||||
|
const analysis = await this.analyzePaidInvoice({ invoiceId, paidAt });
|
||||||
|
if (!analysis.ok) {
|
||||||
|
return { inserted: 0, skipped: 0, reason: analysis.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedInvoiceId = Number(analysis.invoice.id);
|
||||||
|
const abonementId = Number(analysis.abonementId);
|
||||||
|
const paidAtDate = analysis.paidAtDate;
|
||||||
|
const byCoffee = analysis.byCoffee;
|
||||||
|
const pools = analysis.pools;
|
||||||
|
const currency = analysis.currency;
|
||||||
|
const conn = await db.getConnection();
|
||||||
|
let inserted = 0;
|
||||||
|
try {
|
||||||
|
let alreadyExists = 0;
|
||||||
|
await conn.beginTransaction();
|
||||||
|
for (const pool of pools) {
|
||||||
|
const coffeeId = Number(pool.subscription_coffee_id);
|
||||||
|
const capsulesCount = Number(byCoffee.get(coffeeId) || 0);
|
||||||
|
if (!capsulesCount) continue;
|
||||||
|
|
||||||
|
const pricePerCapsuleNet = toFour(pool.price);
|
||||||
|
const amountNet = toTwo(capsulesCount * pricePerCapsuleNet);
|
||||||
|
const details = {
|
||||||
|
source: 'invoice_paid',
|
||||||
|
formula: 'capsules_count * price_per_capsule_net',
|
||||||
|
paid_at: paidAtDate,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [res] = await conn.query(
|
||||||
|
`INSERT INTO pool_inflows
|
||||||
|
(pool_id, invoice_id, abonement_id, coffee_table_id, event_type, capsules_count, price_per_capsule_net, amount_net, currency, details, created_by_user_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'invoice_paid', ?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE id = id`,
|
||||||
|
[
|
||||||
|
Number(pool.id),
|
||||||
|
normalizedInvoiceId,
|
||||||
|
abonementId,
|
||||||
|
coffeeId,
|
||||||
|
capsulesCount,
|
||||||
|
pricePerCapsuleNet,
|
||||||
|
amountNet,
|
||||||
|
currency,
|
||||||
|
JSON.stringify(details),
|
||||||
|
actorUserId || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res && Number(res.affectedRows) === 1) inserted += 1;
|
||||||
|
else alreadyExists += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.commit();
|
||||||
|
return { inserted, alreadyExists, skipped: Math.max(0, pools.length - inserted), reason: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
await conn.rollback();
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
conn.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInvoiceInflowDiagnostics({ invoiceId, paidAt }) {
|
||||||
|
const analysis = await this.analyzePaidInvoice({ invoiceId, paidAt });
|
||||||
|
if (!analysis.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: analysis.reason,
|
||||||
|
invoiceId: Number(invoiceId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceIdNum = Number(analysis.invoice.id);
|
||||||
|
const poolEntries = [];
|
||||||
|
|
||||||
|
for (const pool of analysis.pools) {
|
||||||
|
const coffeeId = Number(pool.subscription_coffee_id);
|
||||||
|
const capsulesCount = Number(analysis.byCoffee.get(coffeeId) || 0);
|
||||||
|
const pricePerCapsuleNet = toFour(pool.price);
|
||||||
|
const amountNet = toTwo(capsulesCount * pricePerCapsuleNet);
|
||||||
|
const [existingRows] = await db.query(
|
||||||
|
`SELECT id, created_at
|
||||||
|
FROM pool_inflows
|
||||||
|
WHERE pool_id = ? AND invoice_id = ? AND coffee_table_id = ? AND event_type = 'invoice_paid'
|
||||||
|
LIMIT 1`,
|
||||||
|
[Number(pool.id), invoiceIdNum, coffeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
poolEntries.push({
|
||||||
|
pool_id: Number(pool.id),
|
||||||
|
pool_name: pool.pool_name,
|
||||||
|
coffee_table_id: coffeeId,
|
||||||
|
capsules_count: capsulesCount,
|
||||||
|
price_per_capsule_net: pricePerCapsuleNet,
|
||||||
|
amount_net: amountNet,
|
||||||
|
already_booked: !!existingRows?.length,
|
||||||
|
existing_inflow_id: existingRows?.[0]?.id || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
reason: 'ok',
|
||||||
|
invoice_id: invoiceIdNum,
|
||||||
|
abonement_id: Number(analysis.abonementId),
|
||||||
|
paid_at: analysis.paidAtDate,
|
||||||
|
currency: analysis.currency,
|
||||||
|
candidates: poolEntries,
|
||||||
|
will_book_count: poolEntries.filter((x) => !x.already_booked).length,
|
||||||
|
already_booked_count: poolEntries.filter((x) => x.already_booked).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new PoolInflowService();
|
||||||
@ -1,22 +1,41 @@
|
|||||||
const UnitOfWork = require('../../database/UnitOfWork');
|
const UnitOfWork = require('../../database/UnitOfWork');
|
||||||
const PoolRepository = require('../../repositories/pool/poolRepository');
|
const PoolRepository = require('../../repositories/pool/poolRepository');
|
||||||
|
const db = require('../../database/database');
|
||||||
|
|
||||||
function isValidPoolType(pool_type) {
|
function isValidPoolType(pool_type) {
|
||||||
return pool_type === 'coffee' || pool_type === 'other';
|
return pool_type === 'coffee' || pool_type === 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPool({ pool_name, description = null, price = 0.00, pool_type = 'other', is_active = true, created_by = null }) {
|
async function createPool({ pool_name, description = null, price = 0.00, subscription_coffee_id = null, pool_type = 'other', is_active = true, created_by = null }) {
|
||||||
if (!isValidPoolType(pool_type)) {
|
if (!isValidPoolType(pool_type)) {
|
||||||
const err = new Error('Invalid pool_type. Allowed: coffee, other');
|
const err = new Error('Invalid pool_type. Allowed: coffee, other');
|
||||||
err.status = 400;
|
err.status = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let normalizedSubscriptionCoffeeId = null;
|
||||||
|
if (subscription_coffee_id !== null && subscription_coffee_id !== undefined && String(subscription_coffee_id).trim() !== '') {
|
||||||
|
const sid = Number(subscription_coffee_id);
|
||||||
|
if (!Number.isFinite(sid) || sid <= 0) {
|
||||||
|
const err = new Error('Invalid subscription_coffee_id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const [rows] = await db.query('SELECT id FROM coffee_table WHERE id = ? LIMIT 1', [sid]);
|
||||||
|
if (!rows.length) {
|
||||||
|
const err = new Error('Selected subscription not found');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
normalizedSubscriptionCoffeeId = sid;
|
||||||
|
}
|
||||||
|
|
||||||
const uow = new UnitOfWork();
|
const uow = new UnitOfWork();
|
||||||
try {
|
try {
|
||||||
console.debug('[PoolService.createPool] start', { pool_name, pool_type });
|
console.debug('[PoolService.createPool] start', { pool_name, pool_type, subscription_coffee_id: normalizedSubscriptionCoffeeId });
|
||||||
await uow.start();
|
await uow.start();
|
||||||
const repo = new PoolRepository(uow);
|
const repo = new PoolRepository(uow);
|
||||||
const pool = await repo.create({ pool_name, description, price, pool_type, is_active, created_by });
|
const pool = await repo.create({ pool_name, description, price, subscription_coffee_id: normalizedSubscriptionCoffeeId, pool_type, is_active, created_by });
|
||||||
await uow.commit();
|
await uow.commit();
|
||||||
console.debug('[PoolService.createPool] success', { id: pool.id });
|
console.debug('[PoolService.createPool] success', { id: pool.id });
|
||||||
return pool;
|
return pool;
|
||||||
@ -67,4 +86,35 @@ async function updatePoolState(id, is_active, actorUserId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createPool, listPools, updatePoolState };
|
async function updatePoolSubscription({ id, subscription_coffee_id = null, actorUserId = null }) {
|
||||||
|
let normalizedSubscriptionCoffeeId = null;
|
||||||
|
if (subscription_coffee_id !== null && subscription_coffee_id !== undefined && String(subscription_coffee_id).trim() !== '') {
|
||||||
|
const sid = Number(subscription_coffee_id);
|
||||||
|
if (!Number.isFinite(sid) || sid <= 0) {
|
||||||
|
const err = new Error('Invalid subscription_coffee_id');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const [rows] = await db.query('SELECT id FROM coffee_table WHERE id = ? LIMIT 1', [sid]);
|
||||||
|
if (!rows.length) {
|
||||||
|
const err = new Error('Selected subscription not found');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
normalizedSubscriptionCoffeeId = sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uow = new UnitOfWork();
|
||||||
|
try {
|
||||||
|
await uow.start();
|
||||||
|
const repo = new PoolRepository(uow);
|
||||||
|
const updated = await repo.updateSubscriptionLink(id, normalizedSubscriptionCoffeeId, actorUserId);
|
||||||
|
await uow.commit();
|
||||||
|
return updated;
|
||||||
|
} catch (err) {
|
||||||
|
try { await uow.rollback(); } catch (_) { console.warn('[PoolService.updatePoolSubscription] rollback failed'); }
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createPool, listPools, updatePoolState, updatePoolSubscription };
|
||||||
Loading…
Reference in New Issue
Block a user