diff --git a/src/config/quickbooks.js b/src/config/quickbooks.js new file mode 100644 index 00000000..ee8db00e --- /dev/null +++ b/src/config/quickbooks.js @@ -0,0 +1,16 @@ +// src/config/quickbooks.js +require('dotenv').config(); + +module.exports = { + clientId: process.env.QB_CLIENT_ID, + clientSecret: process.env.QB_CLIENT_SECRET, + // 'sandbox' for dev/testing, switch to 'production' when you go live + environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox', + // Must exactly match what you configured in the Intuit Developer Portal + redirectUri: process.env.QB_REDIRECT_URI, + scopes: [ + 'com.intuit.quickbooks.accounting', + 'com.intuit.quickbooks.payment' + // add 'openid', 'profile', etc., here only if you need them + ] +}; diff --git a/src/features/courses/course.routes.js b/src/features/courses/course.routes.js new file mode 100644 index 00000000..29dc7aa7 --- /dev/null +++ b/src/features/courses/course.routes.js @@ -0,0 +1,10 @@ +const express = require('express'); +const router = express.Router(); +const { createCourse, getCourseById, updateCourse, deleteCourse } = require('./course.service'); + +router.post('/', createCourse); +router.get('/:id', getCourseById); +router.put('/:id', updateCourse); +router.delete('/:id', deleteCourse); + +module.exports = router; diff --git a/src/features/courses/course.schema.js b/src/features/courses/course.schema.js new file mode 100644 index 00000000..e69de29b diff --git a/src/features/courses/course.service.js b/src/features/courses/course.service.js new file mode 100644 index 00000000..5f4387ff --- /dev/null +++ b/src/features/courses/course.service.js @@ -0,0 +1,91 @@ +const supabase = require('../../config/supabase'); + + +const createCourse = async (req, res, next) => { + const { title, price, description, video } = req.body; + + if (!title || price === undefined || !description || !video) { + return res.status(400).json({ error: 'All fields are required' }); + } + + try { + const { error } = await supabase + .from('courses') + .insert([{ title, price, description, video }]); + + if (error) throw error; + + return res.status(200).end(); + } catch (err) { + next(err); + } +}; + + +// GET /courses/:id +const getCourseById = async (req, res, next) => { + const { id } = req.params; + + try { + const { data, error } = await supabase + .from('courses') + .select('title, price, description, video') + .eq('id', id) + .single(); + + if (error || !data) { + return res.status(400).json({ error: 'Course not found' }); + } + + res.status(200).json(data); + } catch (err) { + next(err); + } +}; + + +// PUT /courses/:id +const updateCourse = async (req, res, next) => { + const { id } = req.params; + const { title, price, description, video } = req.body; + + if (!title || price === undefined || !description || !video) { + return res.status(400).json({ error: 'All fields are required' }); + } + + try { + const { error } = await supabase + .from('courses') + .update({ title, price, description, video }) + .eq('id', id); + + if (error) throw error; + + res.status(200).end(); + } catch (err) { + next(err); + } +}; + + +// DELETE /courses/:id +const deleteCourse = async (req, res, next) => { + const { id } = req.params; + + try { + const { error } = await supabase.from('courses').delete().eq('id', id); + + if (error) throw error; + + res.status(200).end(); + } catch (err) { + next(err); + } +}; + +module.exports = { + createCourse, + getCourseById, + updateCourse, + deleteCourse, +}; diff --git a/src/features/quickbooks/controller/quickbooksController.js b/src/features/quickbooks/controller/quickbooksController.js new file mode 100644 index 00000000..e69de29b diff --git a/src/features/quickbooks/middlewares/ensureQBAuth.js b/src/features/quickbooks/middlewares/ensureQBAuth.js new file mode 100644 index 00000000..e69de29b diff --git a/src/features/quickbooks/migrations/001-create-quickbooks-tokens-table.sql b/src/features/quickbooks/migrations/001-create-quickbooks-tokens-table.sql new file mode 100644 index 00000000..32f64f4d --- /dev/null +++ b/src/features/quickbooks/migrations/001-create-quickbooks-tokens-table.sql @@ -0,0 +1 @@ +t \ No newline at end of file diff --git a/src/features/quickbooks/services/quickbooksAuthService.js b/src/features/quickbooks/services/quickbooksAuthService.js new file mode 100644 index 00000000..ca05e279 --- /dev/null +++ b/src/features/quickbooks/services/quickbooksAuthService.js @@ -0,0 +1,73 @@ +// src/features/quickbooks/services/quickbooksAuthService.js +const QuickBooks = require('node-quickbooks'); +const { + clientId, + clientSecret, + environment, + redirectUri, + scopes +} = require('../../../config/quickbooks'); +const { loadTokens, saveTokens } = require('../utils/tokenUtils'); + +// Initialize the Intuit OAuth client +const oauthClient = new QuickBooks.OAuthClient({ + clientId, + clientSecret, + environment, // 'sandbox' or 'production' + redirectUri // must match your env var and QuickBooks app settings +}); + +/** + * Generate the URL that the merchant must visit to consent to your app. + * @param {string} state A CSRF token or random string to validate the callback + * @returns {string} Full OAuth2 consent URL + */ +function generateConsentUrl(state) { + return oauthClient.authorizeUri({ + scope: scopes, // e.g. ['com.intuit.quickbooks.accounting','com.intuit.quickbooks.payment'] + state + }); +} + +/** + * After QuickBooks redirects back to your callback endpoint, exchange + * the full callback URL (with code & realmId) for access & refresh tokens, + * then persist them. + * + * @param {string} callbackUrl full req.url from Express (including ?code=…&realmId=…) + * @param {number} merchantId your internal ID for this merchant + * @returns {Promise<{ + * realmId: string, + * accessToken: string, + * refreshToken: string, + * expiresAt: string + * }>} + */ +async function handleAuthCallback(callbackUrl, merchantId) { + // Exchange the authorization code for tokens + const authResponse = await oauthClient.createToken(callbackUrl); + + // Extract the JSON and relevant fields + const tokenJson = authResponse.getJson(); + const { access_token, refresh_token, expires_in } = tokenJson; + const realmId = authResponse.token.realmId; + + // Calculate an absolute expiry timestamp + const expiresAt = new Date(Date.now() + expires_in * 1000).toISOString(); + + // Persist into Supabase via our tokenUtils + await saveTokens({ + merchantId, + realmId, + accessToken: access_token, + refreshToken: refresh_token, + expiresAt + }); + + return { realmId, accessToken: access_token, refreshToken: refresh_token, expiresAt }; +} + +module.exports = { + generateConsentUrl, + handleAuthCallback +}; diff --git a/src/features/quickbooks/services/quickbooksPaymentService.js b/src/features/quickbooks/services/quickbooksPaymentService.js new file mode 100644 index 00000000..e69de29b diff --git a/src/features/quickbooks/utils/tokenUtils.js b/src/features/quickbooks/utils/tokenUtils.js new file mode 100644 index 00000000..4d23abc5 --- /dev/null +++ b/src/features/quickbooks/utils/tokenUtils.js @@ -0,0 +1,71 @@ +// src/features/quickbooks/utils/tokenUtils.js +// Helpers for loading and saving QuickBooks tokens using Supabase + +// Import your initialized Supabase client +const supabase = require('../../../../config/supabase'); + +/** + * Fetch stored QuickBooks tokens for a given merchant. + * @param {number} merchantId + * @returns {Promise<{ + * merchantId: number, + * realmId: string, + * accessToken: string, + * refreshToken: string, + * expiresAt: string + * }|null>} + */ +async function loadTokens(merchantId) { + const { data, error } = await supabase + .from('quickbooks_tokens') + .select('merchant_id, realm_id, access_token, refresh_token, expires_at') + .eq('merchant_id', merchantId) + .single(); + + if (error) { + // If no row found, return null; otherwise, propagate error + if (error.code === 'PGRST116') return null; + throw error; + } + + return { + merchantId: data.merchant_id, + realmId: data.realm_id, + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: data.expires_at + }; +} + +/** + * Insert or update QuickBooks tokens for a merchant. + * Relies on a unique constraint on `merchant_id` for upsert. + * @param {Object} params + * @param {number} params.merchantId + * @param {string} params.realmId + * @param {string} params.accessToken + * @param {string} params.refreshToken + * @param {Date|string} params.expiresAt + */ +async function saveTokens({ merchantId, realmId, accessToken, refreshToken, expiresAt }) { + const { error } = await supabase + .from('quickbooks_tokens') + .upsert( + { + merchant_id: merchantId, + realm_id: realmId, + access_token: accessToken, + refresh_token: refreshToken, + expires_at: expiresAt, + updated_at: new Date().toISOString() + }, + { onConflict: 'merchant_id' } + ); + + if (error) throw error; +} + +module.exports = { + loadTokens, + saveTokens +}; diff --git a/testConnection.js b/testConnection.js new file mode 100644 index 00000000..c2f7af71 --- /dev/null +++ b/testConnection.js @@ -0,0 +1,19 @@ +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config(); + +const supabase = createClient( + process.env.SUPABASE_URL, + process.env.SUPABASE_SERVICE_ROLE_KEY +); + +async function testDB() { + const { data, error } = await supabase.from('courses').select('*').limit(1); + + if (error) { + console.error('❌ Connection Failed:', error.message); + } else { + console.log('✅ Connection Successful! Sample data:', data); + } +} + +testDB();