Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test-backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
env:
NODE_ENV: test
SUPABASE_URL: ${{ secrets.SUPABASE_URL }}
SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
JWT_SECRET: test-secret
ADMIN_API_KEY: test-admin-key
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- run: npm ci
- run: npm test -- --coverage --ci
- uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: backend/coverage/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# SYNCRO

![Tests](https://github.com/Calebux/SYNCRO/actions/workflows/test.yml/badge.svg)

## Synchro — Self-Custodial Subscription Manager (MVP)

Synchro is a decentralized, self-custodial subscription management platform that empowers users to take full control of their recurring payments while using crypto. This MVP focuses on gift card–compatible subscriptions and optional email-based subscription detection, pending future automation with non-custodial card issuance on Stellar.
Expand Down
8 changes: 8 additions & 0 deletions backend/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,12 @@ module.exports = {
transformIgnorePatterns: [
'/node_modules/(?!@stellar/stellar-sdk)',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
};
33 changes: 33 additions & 0 deletions backend/migrations/20260329114500_create_monitoring_rpc.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
-- Create an RPC to fetch subscription metrics efficiently without full table scan in application layer
CREATE OR REPLACE FUNCTION get_subscription_metrics()
RETURNS JSON AS $$
DECLARE
result JSON;
BEGIN
SELECT json_build_object(
'total_subscriptions', COUNT(*),
'active_subscriptions', COUNT(*) FILTER (WHERE status = 'active'),
'category_distribution', (
SELECT json_object_agg(category, cat_count)
FROM (
SELECT category, COUNT(*) as cat_count
FROM subscriptions
GROUP BY category
) c
),
'total_monthly_revenue', COALESCE(
SUM(
CASE
WHEN status = 'active' AND billing_cycle = 'yearly' THEN price / 12
WHEN status = 'active' AND billing_cycle = 'weekly' THEN price * 4
WHEN status = 'active' THEN price
ELSE 0
END
), 0
)
) INTO result
FROM subscriptions;

RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Create notifications table
CREATE TABLE IF NOT EXISTS public.notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
metadata JSONB DEFAULT '{}'::jsonb,
read BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);

-- Index for querying user notifications
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON public.notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id_read ON public.notifications(user_id, read);

-- Create monthly budgets table
CREATE TABLE IF NOT EXISTS public.monthly_budgets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
category VARCHAR(50), -- NULL means overall budget
budget_limit DECIMAL(12, 2) NOT NULL,
alert_threshold DECIMAL(5, 2) DEFAULT 80.0, -- percentage
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
UNIQUE(user_id, category)
);

-- Index for budgets
CREATE INDEX IF NOT EXISTS idx_monthly_budgets_user_id ON public.monthly_budgets(user_id);

-- Add real-time replication for notifications
ALTER PUBLICATION supabase_realtime ADD TABLE notifications;

-- RLS Policies
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.monthly_budgets ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users can view their own notifications"
ON public.notifications FOR SELECT
USING (auth.uid() = user_id);

CREATE POLICY "Users can update their own notifications"
ON public.notifications FOR UPDATE
USING (auth.uid() = user_id);

CREATE POLICY "Users can view their own budgets"
ON public.monthly_budgets FOR SELECT
USING (auth.uid() = user_id);

CREATE POLICY "Users can insert their own budgets"
ON public.monthly_budgets FOR INSERT
WITH CHECK (auth.uid() = user_id);

CREATE POLICY "Users can update their own budgets"
ON public.monthly_budgets FOR UPDATE
USING (auth.uid() = user_id);
6 changes: 3 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/express": "^5.0.6",
"@types/express-rate-limit": "^5.1.3",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.14.0",
"@types/node": "^20.19.37",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.14",
"@types/supertest": "^6.0.3",
Expand All @@ -53,6 +53,6 @@
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.0"
"typescript": "^5.9.3"
}
}
4 changes: 2 additions & 2 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import teamRoutes from './routes/team';
import auditRoutes from './routes/audit';
import webhookRoutes from './routes/webhooks';
import tagsRoutes from './routes/tags';
import analyticsRoutes from './routes/analytics';
import { createExchangeRatesRouter } from './routes/exchange-rates';
import { ExchangeRateService } from './services/exchange-rate/exchange-rate-service';
import { FiatRateProvider } from './services/exchange-rate/fiat-provider';
Expand Down Expand Up @@ -78,6 +79,7 @@ app.use('/api/audit', auditRoutes);
app.use('/api/webhooks', webhookRoutes);
app.use('/api/tags', tagsRoutes);
app.use('/api', tagsRoutes); // handles /api/subscriptions/:id/notes and /api/subscriptions/:id/tags
app.use('/api/analytics', analyticsRoutes);
app.use('/api/exchange-rates', createExchangeRatesRouter(exchangeRateService));

// API Routes (Public/Standard)
Expand Down Expand Up @@ -127,8 +129,6 @@ app.get('/api/admin/health', createAdminLimiter(), adminAuth, async (req, res) =
}
});

// Manual trigger endpoints (admin-protected)
app.post('/api/reminders/process', adminAuth, async (req, res) => {
// Manual trigger endpoints (for testing/admin - Should eventually be protected)
app.post('/api/reminders/process', createAdminLimiter(), adminAuth, async (req, res) => {
try {
Expand Down
45 changes: 45 additions & 0 deletions backend/src/routes/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Router, Response } from 'express';
import { analyticsService } from '../services/analytics-service';
import { authenticate, AuthenticatedRequest } from '../middleware/auth';
import logger from '../config/logger';

const router = Router();

// All analytics routes require authentication
router.use(authenticate);

/**
* GET /api/analytics/summary
* Get spend analytics summary and trends
*/
router.get('/summary', async (req: AuthenticatedRequest, res: Response) => {
try {
const summary = await analyticsService.getSummary(req.user!.id);
res.json({
success: true,
data: summary
});
} catch (error) {
logger.error('Analytics summary error:', error);
res.status(500).json({
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch analytics summary'
});
}
});

/**
* GET /api/analytics/budgets
* Get user budgets
*/
router.get('/budgets', async (req: AuthenticatedRequest, res: Response) => {
try {
const { data: budgets, error } = await (analyticsService as any).getUserBudgets(req.user!.id);
if (error) throw error;
res.json({ success: true, data: budgets });
} catch (error) {
res.status(500).json({ success: false, error: 'Failed to fetch budgets' });
}
});

export default router;
Loading
Loading