billing-store.mjs
190 lines 5.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * Persistent billing DB: local file data/hosted_billing.json or Netlify Blob (gateway-billing).
3 */
4 import { normalizeBillingUser } from './billing-logic.mjs';
5 import fs from 'fs/promises';
6 import path from 'path';
7 import { fileURLToPath } from 'url';
8
9 let projectRoot;
10 try {
11 const __dirname = path.dirname(fileURLToPath(import.meta.url));
12 projectRoot = path.resolve(__dirname, '..', '..');
13 } catch (_) {
14 projectRoot = process.cwd();
15 }
16
17 const BLOB_KEY = 'billing-db-v1';
18 const MAX_EVENTS = 8000;
19
20 /**
21 * Resolve the local billing DB path at call time so tests can isolate via
22 * KNOWTATION_BILLING_DB_PATH or KNOWTATION_GATEWAY_DATA_DIR without sharing
23 * (or hanging on) a corrupt repo-local data/hosted_billing.json.
24 * @returns {string}
25 */
26 function billingFilePath() {
27 if (process.env.KNOWTATION_BILLING_DB_PATH) {
28 return path.resolve(process.env.KNOWTATION_BILLING_DB_PATH);
29 }
30 const dataDir = process.env.KNOWTATION_GATEWAY_DATA_DIR || path.join(projectRoot, 'data');
31 return path.join(dataDir, 'hosted_billing.json');
32 }
33
34 function emptyDb() {
35 return { users: {}, processed_events: [] };
36 }
37
38 function getBlobStore() {
39 return globalThis.__knowtation_gateway_blob;
40 }
41
42 async function readFromBlob() {
43 const store = getBlobStore();
44 if (!store) return null;
45 const raw = await store.get(BLOB_KEY, { type: 'json' });
46 if (!raw) return emptyDb();
47 return normalizeDb(raw);
48 }
49
50 async function writeToBlob(db) {
51 const store = getBlobStore();
52 if (!store) throw new Error('Netlify Blob store not configured');
53 await store.setJSON(BLOB_KEY, db);
54 }
55
56 async function readFromFile() {
57 const billingFile = billingFilePath();
58 try {
59 const raw = await fs.readFile(billingFile, 'utf8');
60 return normalizeDb(JSON.parse(raw));
61 } catch (e) {
62 if (e.code === 'ENOENT') return emptyDb();
63 throw e;
64 }
65 }
66
67 async function writeToFile(db) {
68 const billingFile = billingFilePath();
69 await fs.mkdir(path.dirname(billingFile), { recursive: true });
70 await fs.writeFile(billingFile, JSON.stringify(db, null, 2), 'utf8');
71 }
72
73 function normalizeDb(raw) {
74 const db = raw && typeof raw === 'object' ? raw : emptyDb();
75 if (!db.users || typeof db.users !== 'object') db.users = {};
76 if (!Array.isArray(db.processed_events)) db.processed_events = [];
77 for (const uid of Object.keys(db.users)) {
78 normalizeBillingUser(db.users[uid]);
79 }
80 return db;
81 }
82
83 export async function loadBillingDb() {
84 if (getBlobStore()) {
85 return readFromBlob();
86 }
87 return readFromFile();
88 }
89
90 export async function saveBillingDb(db) {
91 if (getBlobStore()) {
92 await writeToBlob(db);
93 } else {
94 await writeToFile(db);
95 }
96 }
97
98 /**
99 * In-process write queue. Serializes all mutateBillingDb calls so that concurrent
100 * requests within the same process (tests, local dev, single Netlify function instance)
101 * never interleave their read-modify-write cycles.
102 *
103 * Note: across separate Netlify function instances (cold starts, concurrent invocations
104 * handled by different workers) this queue has no effect — the backing Blob store is the
105 * only coordination point there. But eliminating in-process races is sufficient to keep
106 * CI stable and to prevent data loss during high-throughput local dev.
107 */
108 let _mutationQueue = Promise.resolve();
109
110 /**
111 * @param {(db: object) => void} fn - mutates db in place
112 */
113 export async function mutateBillingDb(fn) {
114 const run = _mutationQueue.then(async () => {
115 const db = await loadBillingDb();
116 fn(db);
117 trimEvents(db);
118 await saveBillingDb(db);
119 });
120 // Keep the queue alive even if this call throws; errors propagate to the caller, not the chain.
121 _mutationQueue = run.catch(() => {});
122 return run;
123 }
124
125 function trimEvents(db) {
126 while (db.processed_events.length > MAX_EVENTS) {
127 db.processed_events.shift();
128 }
129 }
130
131 export function eventAlreadyProcessed(db, eventId) {
132 return db.processed_events.includes(eventId);
133 }
134
135 export function markEventProcessed(db, eventId) {
136 if (!db.processed_events.includes(eventId)) db.processed_events.push(eventId);
137 }
138
139 export function findUserIdByCustomerId(db, customerId) {
140 if (!customerId) return null;
141 for (const uid of Object.keys(db.users)) {
142 if (db.users[uid].stripe_customer_id === customerId) return uid;
143 }
144 return null;
145 }
146
147 /**
148 * If the user's billing period has expired, reset monthly_indexing_tokens_used to 0 and
149 * advance period_start / period_end by one calendar month.
150 *
151 * This is a client-side guard for cases where the `invoice.paid` webhook is delayed or missed.
152 * It does NOT reset the credit (cents) ledger — that is handled by the Stripe invoice webhook.
153 *
154 * @param {string} userId
155 * @returns {Promise<void>}
156 */
157 export async function resetMonthlyTokensIfNeeded(userId) {
158 if (!userId) return;
159 const db = await loadBillingDb();
160 const u = db.users[userId];
161 if (!u) return;
162
163 const periodEnd = u.period_end ? new Date(u.period_end) : null;
164 if (!periodEnd || isNaN(periodEnd.getTime())) return;
165
166 const now = new Date();
167 if (now <= periodEnd) return;
168
169 await mutateBillingDb((dbMut) => {
170 const user = dbMut.users[userId];
171 if (!user) return;
172
173 const pe = new Date(user.period_end);
174 if (isNaN(pe.getTime()) || now <= pe) return;
175
176 // Reset all monthly counters.
177 user.monthly_indexing_tokens_used = 0;
178 user.monthly_used_cents = 0;
179 user.monthly_searches_used = 0;
180 user.monthly_index_jobs_used = 0;
181 user.monthly_consolidation_jobs_used = 0;
182
183 // Advance period by one month.
184 const newStart = new Date(pe);
185 const newEnd = new Date(pe);
186 newEnd.setMonth(newEnd.getMonth() + 1);
187 user.period_start = newStart.toISOString();
188 user.period_end = newEnd.toISOString();
189 });
190 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago