tractatus/src/models/Donation.model.js
TheFlow ebfeadb900 feat: implement Koha donation system backend (Phase 3)
Backend API complete for NZD donation processing via Stripe.

**New Backend Components:**

Database Model:
- src/models/Donation.model.js - Donation schema with privacy-first design
  - Anonymous donations by default, opt-in public acknowledgement
  - Monthly recurring and one-time donation support
  - Stripe integration (customer, subscription, payment tracking)
  - Public transparency metrics aggregation
  - Admin statistics and reporting

Service Layer:
- src/services/koha.service.js - Stripe integration service
  - Checkout session creation (monthly + one-time)
  - Webhook event processing (8 event types)
  - Subscription management (cancel, update)
  - Receipt email generation (placeholder)
  - Transparency metrics calculation
  - Based on passport-consolidated StripeService pattern

Controller:
- src/controllers/koha.controller.js - HTTP request handlers
  - POST /api/koha/checkout - Create donation checkout
  - POST /api/koha/webhook - Stripe webhook receiver
  - GET /api/koha/transparency - Public metrics
  - POST /api/koha/cancel - Cancel recurring donation
  - GET /api/koha/verify/:sessionId - Verify payment status
  - GET /api/koha/statistics - Admin statistics

Routes:
- src/routes/koha.routes.js - API endpoint definitions
- src/routes/index.js - Koha routes registered

**Infrastructure:**

Server Configuration:
- src/server.js - Raw body parsing for Stripe webhooks
  - Required for webhook signature verification
  - Route-specific middleware for /api/koha/webhook

Environment Variables:
- .env.example - Koha/Stripe configuration template
  - Stripe API keys (reuses passport-consolidated account)
  - Price IDs for NZD monthly tiers ($5, $15, $50)
  - Webhook secret for signature verification
  - Frontend URL for payment redirects

**Documentation:**

- docs/KOHA_STRIPE_SETUP.md - Complete setup guide
  - Step-by-step Stripe Dashboard configuration
  - Product and price creation instructions
  - Webhook endpoint setup
  - Testing procedures with test cards
  - Security and compliance notes
  - Production deployment checklist

**Key Features:**

 Privacy-first design (anonymous by default)
 NZD currency support (New Zealand Dollars)
 Monthly recurring subscriptions ($5, $15, $50 NZD)
 One-time custom donations
 Public transparency dashboard metrics
 Stripe webhook signature verification
 Subscription cancellation support
 Receipt tracking (email generation ready)
 Admin statistics and reporting

**Architecture:**

- Reuses existing Stripe account from passport-consolidated
- Separate webhook endpoint (/api/koha/webhook vs /api/stripe/webhook)
- Separate MongoDB collection (koha_donations)
- Compatible with existing infrastructure

**Next Steps:**

- Create Stripe products in Dashboard (use setup guide)
- Build donation form frontend UI
- Create transparency dashboard page
- Implement receipt email service
- Test end-to-end with Stripe test cards
- Deploy to production

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 13:35:40 +13:00

322 lines
9.2 KiB
JavaScript

/**
* Donation Model
* Koha (donation) system for Tractatus Framework support
*
* Privacy-first design:
* - Anonymous donations by default
* - Opt-in public acknowledgement
* - Email stored securely for receipts only
* - Public transparency metrics
*/
const { ObjectId } = require('mongodb');
const { getCollection } = require('../utils/db.util');
class Donation {
/**
* Create a new donation record
*/
static async create(data) {
const collection = await getCollection('koha_donations');
const donation = {
// Donation details
amount: data.amount, // In cents (NZD)
currency: data.currency || 'nzd',
frequency: data.frequency, // 'monthly' or 'one_time'
tier: data.tier, // '5', '15', '50', or 'custom'
// Donor information (private)
donor: {
name: data.donor?.name || 'Anonymous',
email: data.donor?.email, // Required for receipt, kept private
country: data.donor?.country,
// Do NOT store full address unless required for tax purposes
},
// Public acknowledgement (opt-in)
public_acknowledgement: data.public_acknowledgement || false,
public_name: data.public_name || null, // Name to show publicly if opted in
// Stripe integration
stripe: {
customer_id: data.stripe?.customer_id,
subscription_id: data.stripe?.subscription_id, // For monthly donations
payment_intent_id: data.stripe?.payment_intent_id,
charge_id: data.stripe?.charge_id,
invoice_id: data.stripe?.invoice_id
},
// Status tracking
status: data.status || 'pending', // pending, completed, failed, cancelled, refunded
payment_date: data.payment_date || new Date(),
// Receipt tracking
receipt: {
sent: false,
sent_date: null,
receipt_number: null
},
// Metadata
metadata: {
source: data.metadata?.source || 'website', // website, api, manual
campaign: data.metadata?.campaign,
referrer: data.metadata?.referrer,
user_agent: data.metadata?.user_agent,
ip_country: data.metadata?.ip_country
},
// Timestamps
created_at: new Date(),
updated_at: new Date()
};
const result = await collection.insertOne(donation);
return { ...donation, _id: result.insertedId };
}
/**
* Find donation by ID
*/
static async findById(id) {
const collection = await getCollection('koha_donations');
return await collection.findOne({ _id: new ObjectId(id) });
}
/**
* Find donation by Stripe subscription ID
*/
static async findBySubscriptionId(subscriptionId) {
const collection = await getCollection('koha_donations');
return await collection.findOne({ 'stripe.subscription_id': subscriptionId });
}
/**
* Find donation by Stripe payment intent ID
*/
static async findByPaymentIntentId(paymentIntentId) {
const collection = await getCollection('koha_donations');
return await collection.findOne({ 'stripe.payment_intent_id': paymentIntentId });
}
/**
* Find all donations by donor email (for admin/receipt purposes)
*/
static async findByDonorEmail(email) {
const collection = await getCollection('koha_donations');
return await collection.find({ 'donor.email': email }).sort({ created_at: -1 }).toArray();
}
/**
* Update donation status
*/
static async updateStatus(id, status, additionalData = {}) {
const collection = await getCollection('koha_donations');
const updateData = {
status,
updated_at: new Date(),
...additionalData
};
const result = await collection.updateOne(
{ _id: new ObjectId(id) },
{ $set: updateData }
);
return result.modifiedCount > 0;
}
/**
* Mark receipt as sent
*/
static async markReceiptSent(id, receiptNumber) {
const collection = await getCollection('koha_donations');
const result = await collection.updateOne(
{ _id: new ObjectId(id) },
{
$set: {
'receipt.sent': true,
'receipt.sent_date': new Date(),
'receipt.receipt_number': receiptNumber,
updated_at: new Date()
}
}
);
return result.modifiedCount > 0;
}
/**
* Cancel recurring donation (subscription)
*/
static async cancelSubscription(subscriptionId) {
const collection = await getCollection('koha_donations');
const result = await collection.updateOne(
{ 'stripe.subscription_id': subscriptionId },
{
$set: {
status: 'cancelled',
updated_at: new Date()
}
}
);
return result.modifiedCount > 0;
}
/**
* Get transparency metrics (PUBLIC DATA)
* Returns aggregated data for public transparency dashboard
*/
static async getTransparencyMetrics() {
const collection = await getCollection('koha_donations');
// Get all completed donations
const completedDonations = await collection.find({
status: 'completed'
}).toArray();
// Calculate totals
const totalReceived = completedDonations.reduce((sum, d) => sum + d.amount, 0) / 100; // Convert to NZD
// Count monthly supporters (active subscriptions)
const monthlyDonations = completedDonations.filter(d => d.frequency === 'monthly');
const activeSubscriptions = monthlyDonations.filter(d => d.status === 'completed');
const monthlySupporters = new Set(activeSubscriptions.map(d => d.stripe.customer_id)).size;
// Count one-time donations
const oneTimeDonations = completedDonations.filter(d => d.frequency === 'one_time').length;
// Get public acknowledgements (donors who opted in)
const publicDonors = completedDonations
.filter(d => d.public_acknowledgement && d.public_name)
.sort((a, b) => b.created_at - a.created_at)
.slice(0, 20) // Latest 20 donors
.map(d => ({
name: d.public_name,
amount: d.amount / 100,
date: d.created_at,
frequency: d.frequency
}));
// Calculate monthly recurring revenue
const monthlyRevenue = activeSubscriptions.reduce((sum, d) => sum + d.amount, 0) / 100;
// Allocation breakdown (as per specification)
const allocation = {
hosting: 0.30,
development: 0.40,
research: 0.20,
community: 0.10
};
return {
total_received: totalReceived,
monthly_supporters: monthlySupporters,
one_time_donations: oneTimeDonations,
monthly_recurring_revenue: monthlyRevenue,
allocation: allocation,
recent_donors: publicDonors,
last_updated: new Date()
};
}
/**
* Get donation statistics (ADMIN ONLY)
*/
static async getStatistics(startDate = null, endDate = null) {
const collection = await getCollection('koha_donations');
const query = { status: 'completed' };
if (startDate || endDate) {
query.created_at = {};
if (startDate) query.created_at.$gte = new Date(startDate);
if (endDate) query.created_at.$lte = new Date(endDate);
}
const donations = await collection.find(query).toArray();
return {
total_count: donations.length,
total_amount: donations.reduce((sum, d) => sum + d.amount, 0) / 100,
by_frequency: {
monthly: donations.filter(d => d.frequency === 'monthly').length,
one_time: donations.filter(d => d.frequency === 'one_time').length
},
by_tier: {
tier_5: donations.filter(d => d.tier === '5').length,
tier_15: donations.filter(d => d.tier === '15').length,
tier_50: donations.filter(d => d.tier === '50').length,
custom: donations.filter(d => d.tier === 'custom').length
},
average_donation: donations.length > 0
? (donations.reduce((sum, d) => sum + d.amount, 0) / donations.length) / 100
: 0,
public_acknowledgements: donations.filter(d => d.public_acknowledgement).length
};
}
/**
* Get all donations (ADMIN ONLY - paginated)
*/
static async findAll(options = {}) {
const collection = await getCollection('koha_donations');
const {
page = 1,
limit = 20,
status = null,
frequency = null,
sortBy = 'created_at',
sortOrder = -1
} = options;
const query = {};
if (status) query.status = status;
if (frequency) query.frequency = frequency;
const skip = (page - 1) * limit;
const donations = await collection
.find(query)
.sort({ [sortBy]: sortOrder })
.skip(skip)
.limit(limit)
.toArray();
const total = await collection.countDocuments(query);
return {
donations,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Create database indexes for performance
*/
static async createIndexes() {
const collection = await getCollection('koha_donations');
await collection.createIndex({ status: 1 });
await collection.createIndex({ frequency: 1 });
await collection.createIndex({ 'stripe.subscription_id': 1 });
await collection.createIndex({ 'stripe.payment_intent_id': 1 });
await collection.createIndex({ 'donor.email': 1 });
await collection.createIndex({ created_at: -1 });
await collection.createIndex({ public_acknowledgement: 1 });
return true;
}
}
module.exports = Donation;