Mbonea commited on
Commit
20da685
·
1 Parent(s): fbe13e1

Add token compensation support for disconnected clients

Browse files
API_DOCUMENTATION.md CHANGED
@@ -699,6 +699,58 @@ The token must still belong to the authenticated tenant, still be valid, and sti
699
 
700
  ---
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  ### GET `/api/sessions`
703
  Session history (active and ended).
704
 
 
699
 
700
  ---
701
 
702
+ ### POST `/api/sessions/paid-disconnected/:tokenId/compensate`
703
+ Add free compensation time to an affected paid client by extending the original purchased token.
704
+
705
+ This is a goodwill extension only:
706
+ - no new payment is created
707
+ - revenue, balance, payouts, and ledgers are unchanged
708
+ - the original token code stays the same
709
+ - an audit record is written server-side
710
+
711
+ The token must belong to the authenticated tenant and still be attached to a device in `offline`, `pending`, or `adopting`.
712
+
713
+ **Auth:** Required
714
+
715
+ **Body:**
716
+ ```json
717
+ {
718
+ "minutes": 120,
719
+ "reason": "ISP outage compensation"
720
+ }
721
+ ```
722
+
723
+ **Validation:**
724
+ - `minutes` must be a positive integer
725
+ - max `minutes` is `10080` (7 days)
726
+ - `reason` optional, max 255 characters
727
+
728
+ **Response `200`:**
729
+ ```json
730
+ {
731
+ "success": true,
732
+ "token_id": 88,
733
+ "token_code": "ABCD-1234",
734
+ "device_id": 3,
735
+ "device_name": "Main Building",
736
+ "status": "active",
737
+ "granted_minutes": 120,
738
+ "granted_seconds": 7200,
739
+ "old_expires_at": "2026-05-10T17:35:00.000Z",
740
+ "new_expires_at": "2026-05-10T19:35:00.000Z",
741
+ "remaining_seconds": 7200,
742
+ "reason": "ISP outage compensation",
743
+ "message": "Compensation time added successfully"
744
+ }
745
+ ```
746
+
747
+ **Notes:**
748
+ - Use this from the Sessions page for outage support.
749
+ - After success, refresh `GET /api/sessions/paid-disconnected`.
750
+ - If the device is still offline, the row will remain in the list with a longer time left.
751
+
752
+ ---
753
+
754
  ### GET `/api/sessions`
755
  Session history (active and ended).
756
 
src/routes/sessions.routes.js CHANGED
@@ -5,6 +5,10 @@ const { requireAuth } = require('../middleware/auth');
5
 
6
  router.use(requireAuth);
7
 
 
 
 
 
8
  // GET /api/sessions/active
9
  router.get('/active', async (req, res) => {
10
  try {
@@ -140,6 +144,108 @@ router.post('/paid-disconnected/:tokenId/message', async (req, res) => {
140
  }
141
  });
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  // GET /api/sessions?deviceId=&limit=
144
  router.get('/', async (req, res) => {
145
  const { deviceId, limit = 50 } = req.query;
 
5
 
6
  router.use(requireAuth);
7
 
8
+ function formatDateTimeLocal(date) {
9
+ return new Date(date).toISOString().slice(0, 19) + 'Z';
10
+ }
11
+
12
  // GET /api/sessions/active
13
  router.get('/active', async (req, res) => {
14
  try {
 
144
  }
145
  });
146
 
147
+ // POST /api/sessions/paid-disconnected/:tokenId/compensate
148
+ router.post('/paid-disconnected/:tokenId/compensate', async (req, res) => {
149
+ const minutes = parseInt(req.body.minutes, 10);
150
+ const reason = req.body.reason ? String(req.body.reason).trim() : null;
151
+
152
+ if (!Number.isInteger(minutes) || minutes <= 0) {
153
+ return res.status(400).json({ error: 'minutes must be a positive integer' });
154
+ }
155
+ if (minutes > 7 * 24 * 60) {
156
+ return res.status(400).json({ error: 'minutes too large (max 10080)' });
157
+ }
158
+ if (reason && reason.length > 255) {
159
+ return res.status(400).json({ error: 'reason too long (max 255 characters)' });
160
+ }
161
+
162
+ const grantedSeconds = minutes * 60;
163
+ const conn = await db.pool.getConnection();
164
+
165
+ try {
166
+ await conn.beginTransaction();
167
+
168
+ const [rows] = await conn.execute(
169
+ `SELECT t.id AS token_id, t.payment_id, t.client_id, t.device_id, t.code AS token_code,
170
+ t.status, t.expires_at, t.duration_seconds,
171
+ d.name AS device_name, d.status AS device_status, d.billing_status
172
+ FROM access_tokens t
173
+ JOIN devices d ON d.id = t.device_id
174
+ JOIN payments p ON p.id = t.payment_id AND p.status = 'completed'
175
+ WHERE t.id = ?
176
+ AND t.client_id = ?
177
+ AND t.payment_id IS NOT NULL
178
+ AND d.billing_status NOT IN ('suspended', 'cancelled')
179
+ AND d.status IN ('offline', 'pending', 'adopting')
180
+ LIMIT 1`,
181
+ [req.params.tokenId, req.client.id]
182
+ );
183
+
184
+ const token = rows[0];
185
+ if (!token) {
186
+ await conn.rollback();
187
+ return res.status(404).json({ error: 'Compensatable client token not found' });
188
+ }
189
+
190
+ const oldExpiry = token.expires_at ? new Date(token.expires_at) : new Date();
191
+ const baseExpiry = oldExpiry > new Date() ? oldExpiry : new Date();
192
+ const newExpiry = new Date(baseExpiry.getTime() + grantedSeconds * 1000);
193
+
194
+ const nextStatus = newExpiry > new Date() ? 'active' : token.status;
195
+ const oldDurationSeconds = Number(token.duration_seconds || 0);
196
+ const nextDurationSeconds = oldDurationSeconds + grantedSeconds;
197
+
198
+ await conn.execute(
199
+ `UPDATE access_tokens
200
+ SET expires_at = ?, duration_seconds = ?, status = ?
201
+ WHERE id = ?`,
202
+ [newExpiry, nextDurationSeconds, nextStatus, token.token_id]
203
+ );
204
+
205
+ await conn.execute(
206
+ `INSERT INTO token_compensations
207
+ (token_id, payment_id, client_id, device_id, actor_type, actor_client_id,
208
+ granted_seconds, old_expires_at, new_expires_at, reason)
209
+ VALUES (?, ?, ?, ?, 'tenant', ?, ?, ?, ?, ?)`,
210
+ [
211
+ token.token_id,
212
+ token.payment_id,
213
+ token.client_id,
214
+ token.device_id,
215
+ req.client.id,
216
+ grantedSeconds,
217
+ token.expires_at || null,
218
+ newExpiry,
219
+ reason,
220
+ ]
221
+ );
222
+
223
+ await conn.commit();
224
+
225
+ res.json({
226
+ success: true,
227
+ token_id: token.token_id,
228
+ token_code: token.token_code,
229
+ device_id: token.device_id,
230
+ device_name: token.device_name,
231
+ status: nextStatus,
232
+ granted_minutes: minutes,
233
+ granted_seconds: grantedSeconds,
234
+ old_expires_at: token.expires_at ? formatDateTimeLocal(token.expires_at) : null,
235
+ new_expires_at: formatDateTimeLocal(newExpiry),
236
+ remaining_seconds: Math.max(0, Math.floor((newExpiry.getTime() - Date.now()) / 1000)),
237
+ reason,
238
+ message: 'Compensation time added successfully',
239
+ });
240
+ } catch (err) {
241
+ await conn.rollback();
242
+ console.error('[sessions/paid-disconnected/compensate]', err.message);
243
+ res.status(500).json({ error: 'Failed to compensate client token' });
244
+ } finally {
245
+ conn.release();
246
+ }
247
+ });
248
+
249
  // GET /api/sessions?deviceId=&limit=
250
  router.get('/', async (req, res) => {
251
  const { deviceId, limit = 50 } = req.query;
src/utils/migrate.js CHANGED
@@ -35,6 +35,29 @@ const migrations = [
35
  // 005 — Fix any invalid payments.status values before modifying the ENUM
36
  `UPDATE payments SET status = 'pending' WHERE status NOT IN ('pending','completed','failed','refunded')`,
37
  `ALTER TABLE payments MODIFY COLUMN status ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending'`,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ];
39
 
40
  async function runMigrations() {
 
35
  // 005 — Fix any invalid payments.status values before modifying the ENUM
36
  `UPDATE payments SET status = 'pending' WHERE status NOT IN ('pending','completed','failed','refunded')`,
37
  `ALTER TABLE payments MODIFY COLUMN status ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending'`,
38
+
39
+ // 006 — Audit trail for tenant/admin customer compensation extensions
40
+ `CREATE TABLE IF NOT EXISTS token_compensations (
41
+ id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
42
+ token_id BIGINT UNSIGNED NOT NULL,
43
+ payment_id BIGINT UNSIGNED NOT NULL,
44
+ client_id INT UNSIGNED NOT NULL,
45
+ device_id INT UNSIGNED NOT NULL,
46
+ actor_type ENUM('tenant','admin') NOT NULL,
47
+ actor_client_id INT UNSIGNED DEFAULT NULL,
48
+ granted_seconds INT UNSIGNED NOT NULL,
49
+ old_expires_at TIMESTAMP NULL DEFAULT NULL,
50
+ new_expires_at TIMESTAMP NOT NULL,
51
+ reason VARCHAR(255) DEFAULT NULL,
52
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
53
+ KEY idx_token_comp_token (token_id),
54
+ KEY idx_token_comp_client (client_id),
55
+ KEY idx_token_comp_payment (payment_id),
56
+ CONSTRAINT fk_token_comp_token FOREIGN KEY (token_id) REFERENCES access_tokens(id) ON DELETE CASCADE,
57
+ CONSTRAINT fk_token_comp_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
58
+ CONSTRAINT fk_token_comp_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
59
+ CONSTRAINT fk_token_comp_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
60
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
61
  ];
62
 
63
  async function runMigrations() {