tractatus/src/services/koha.service.js
TheFlow f042fa67b5 feat(koha): implement Stripe Customer Portal integration
- Add createPortalSession endpoint to koha.controller.js
- Add POST /api/koha/portal route with rate limiting
- Add 'Manage Your Subscription' section to koha.html
- Implement handleManageSubscription() in koha-donation.js
- Add Koha link to navigation menu in navbar.js
- Allow donors to self-manage subscriptions via Stripe portal
- Portal supports: payment method updates, cancellation, invoice history

Ref: Customer Portal setup docs in docs/STRIPE_CUSTOMER_PORTAL_NEXT_STEPS.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 22:19:08 +13:00

515 lines
16 KiB
JavaScript

/**
* Koha Service
* Donation processing service for Tractatus Framework
*
* Based on passport-consolidated's StripeService pattern
* Handles multi-currency donations via Stripe (reusing existing account)
*/
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const Donation = require('../models/Donation.model');
const {
isSupportedCurrency,
convertToNZD,
getExchangeRate
} = require('../config/currencies.config');
// Simple logger (uses console)
const logger = {
info: (...args) => console.log(...args),
error: (...args) => console.error(...args),
warn: (...args) => console.warn(...args)
};
class KohaService {
constructor() {
this.stripe = stripe;
this.priceIds = {
// NZD monthly tiers
monthly_5: process.env.STRIPE_KOHA_5_PRICE_ID,
monthly_15: process.env.STRIPE_KOHA_15_PRICE_ID,
monthly_50: process.env.STRIPE_KOHA_50_PRICE_ID,
// One-time donation (custom amount)
one_time: process.env.STRIPE_KOHA_ONETIME_PRICE_ID
};
}
/**
* Create a Stripe Checkout session for donation
* @param {Object} donationData - Donation details
* @returns {Object} Checkout session data
*/
async createCheckoutSession(donationData) {
try {
const { amount, currency, frequency, tier, donor, public_acknowledgement, public_name } = donationData;
// Validate currency
const donationCurrency = (currency || 'nzd').toUpperCase();
if (!isSupportedCurrency(donationCurrency)) {
throw new Error(`Unsupported currency: ${donationCurrency}`);
}
// Validate inputs
if (!amount || amount < 100) {
throw new Error('Minimum donation amount is $1.00');
}
if (!frequency || !['monthly', 'one_time'].includes(frequency)) {
throw new Error('Invalid frequency. Must be "monthly" or "one_time"');
}
if (!donor?.email) {
throw new Error('Donor email is required for receipt');
}
// Calculate NZD equivalent for transparency metrics
const amountNZD = donationCurrency === 'NZD' ? amount : convertToNZD(amount, donationCurrency);
const exchangeRate = getExchangeRate(donationCurrency);
logger.info(`[KOHA] Creating checkout session: ${frequency} donation of ${donationCurrency} $${amount / 100} (NZD $${amountNZD / 100})`);
// Create or retrieve Stripe customer
let stripeCustomer;
try {
// Search for existing customer by email
const customers = await this.stripe.customers.list({
email: donor.email,
limit: 1
});
if (customers.data.length > 0) {
stripeCustomer = customers.data[0];
logger.info(`[KOHA] Using existing customer ${stripeCustomer.id}`);
} else {
stripeCustomer = await this.stripe.customers.create({
email: donor.email,
name: donor.name || 'Anonymous Donor',
metadata: {
source: 'tractatus_koha',
public_acknowledgement: public_acknowledgement ? 'yes' : 'no'
}
});
logger.info(`[KOHA] Created new customer ${stripeCustomer.id}`);
}
} catch (error) {
logger.error('[KOHA] Failed to create/retrieve customer:', error);
throw new Error('Failed to process donor information');
}
// Prepare checkout session parameters
const sessionParams = {
payment_method_types: ['card'],
customer: stripeCustomer.id,
mode: frequency === 'monthly' ? 'subscription' : 'payment',
success_url: `${process.env.FRONTEND_URL || 'https://agenticgovernance.digital'}/koha/success.html?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL || 'https://agenticgovernance.digital'}/koha.html`,
metadata: {
frequency: frequency,
tier: tier,
currency: donationCurrency,
amount_nzd: String(amountNZD),
exchange_rate: String(exchangeRate),
donor_name: donor.name || 'Anonymous',
public_acknowledgement: public_acknowledgement ? 'yes' : 'no',
public_name: public_name || '',
source: 'tractatus_website'
},
allow_promotion_codes: true, // Allow coupon codes
billing_address_collection: 'auto'
};
// Add line items based on frequency
if (frequency === 'monthly') {
// Subscription mode - use price ID for recurring donations
const priceId = this.priceIds[`monthly_${tier}`];
if (!priceId) {
throw new Error(`Invalid monthly tier: ${tier}`);
}
sessionParams.line_items = [{
price: priceId,
quantity: 1
}];
sessionParams.subscription_data = {
metadata: {
tier: tier,
public_acknowledgement: public_acknowledgement ? 'yes' : 'no'
}
};
} else {
// One-time payment mode - use custom amount
sessionParams.line_items = [{
price_data: {
currency: donationCurrency.toLowerCase(),
product_data: {
name: 'Tractatus Framework Support',
description: 'One-time donation to support the Tractatus Framework for AI safety',
images: ['https://agenticgovernance.digital/images/tractatus-icon.svg']
},
unit_amount: amount // Amount in cents
},
quantity: 1
}];
sessionParams.payment_intent_data = {
metadata: {
tier: tier || 'custom',
public_acknowledgement: public_acknowledgement ? 'yes' : 'no'
}
};
}
// Create checkout session
const session = await this.stripe.checkout.sessions.create(sessionParams);
logger.info(`[KOHA] Checkout session created: ${session.id}`);
// Create pending donation record in database
await Donation.create({
amount: amount,
currency: donationCurrency.toLowerCase(),
amount_nzd: amountNZD,
exchange_rate_to_nzd: exchangeRate,
frequency: frequency,
tier: tier,
donor: {
name: donor.name || 'Anonymous',
email: donor.email,
country: donor.country
},
public_acknowledgement: public_acknowledgement || false,
public_name: public_name || null,
stripe: {
customer_id: stripeCustomer.id
},
status: 'pending',
metadata: {
source: 'website',
session_id: session.id
}
});
return {
sessionId: session.id,
checkoutUrl: session.url,
frequency: frequency,
amount: amount / 100
};
} catch (error) {
logger.error('[KOHA] Checkout session creation failed:', error);
throw error;
}
}
/**
* Handle webhook events from Stripe
* @param {Object} event - Stripe webhook event
*/
async handleWebhook(event) {
try {
logger.info(`[KOHA] Processing webhook event: ${event.type}`);
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutComplete(event.data.object);
break;
case 'payment_intent.succeeded':
await this.handlePaymentSuccess(event.data.object);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentFailure(event.data.object);
break;
case 'invoice.paid':
// Recurring subscription payment succeeded
await this.handleInvoicePaid(event.data.object);
break;
case 'invoice.payment_failed':
// Recurring subscription payment failed
await this.handleInvoicePaymentFailed(event.data.object);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await this.handleSubscriptionUpdate(event.data.object);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionCancellation(event.data.object);
break;
default:
logger.info(`[KOHA] Unhandled webhook event type: ${event.type}`);
}
} catch (error) {
logger.error('[KOHA] Webhook processing error:', error);
throw error;
}
}
/**
* Handle successful checkout completion
*/
async handleCheckoutComplete(session) {
try {
const frequency = session.metadata.frequency;
const tier = session.metadata.tier;
const currency = session.metadata.currency || session.currency?.toUpperCase() || 'NZD';
const amountNZD = session.metadata.amount_nzd ? parseInt(session.metadata.amount_nzd) : session.amount_total;
const exchangeRate = session.metadata.exchange_rate ? parseFloat(session.metadata.exchange_rate) : 1.0;
logger.info(`[KOHA] Checkout completed: ${frequency} donation, tier: ${tier}, currency: ${currency}`);
// Find pending donation or create new one
let donation = await Donation.findByPaymentIntentId(session.payment_intent);
if (!donation) {
// Create donation record from session data
donation = await Donation.create({
amount: session.amount_total,
currency: currency.toLowerCase(),
amount_nzd: amountNZD,
exchange_rate_to_nzd: exchangeRate,
frequency: frequency,
tier: tier,
donor: {
name: session.metadata.donor_name || 'Anonymous',
email: session.customer_email
},
public_acknowledgement: session.metadata.public_acknowledgement === 'yes',
public_name: session.metadata.public_name || null,
stripe: {
customer_id: session.customer,
subscription_id: session.subscription || null,
payment_intent_id: session.payment_intent
},
status: 'completed',
payment_date: new Date()
});
} else {
// Update existing donation
await Donation.updateStatus(donation._id, 'completed', {
'stripe.subscription_id': session.subscription || null,
'stripe.payment_intent_id': session.payment_intent,
payment_date: new Date()
});
}
// Send receipt email (async, don't wait)
this.sendReceiptEmail(donation).catch(err =>
logger.error('[KOHA] Failed to send receipt email:', err)
);
logger.info(`[KOHA] Donation recorded: ${currency} $${session.amount_total / 100} (NZD $${amountNZD / 100})`);
} catch (error) {
logger.error('[KOHA] Error handling checkout completion:', error);
throw error;
}
}
/**
* Handle successful payment
*/
async handlePaymentSuccess(paymentIntent) {
try {
logger.info(`[KOHA] Payment succeeded: ${paymentIntent.id}`);
const donation = await Donation.findByPaymentIntentId(paymentIntent.id);
if (donation && donation.status === 'pending') {
await Donation.updateStatus(donation._id, 'completed', {
payment_date: new Date()
});
}
} catch (error) {
logger.error('[KOHA] Error handling payment success:', error);
}
}
/**
* Handle failed payment
*/
async handlePaymentFailure(paymentIntent) {
try {
logger.warn(`[KOHA] Payment failed: ${paymentIntent.id}`);
const donation = await Donation.findByPaymentIntentId(paymentIntent.id);
if (donation) {
await Donation.updateStatus(donation._id, 'failed', {
'metadata.failure_reason': paymentIntent.last_payment_error?.message
});
}
} catch (error) {
logger.error('[KOHA] Error handling payment failure:', error);
}
}
/**
* Handle paid invoice (recurring subscription payment)
*/
async handleInvoicePaid(invoice) {
try {
logger.info(`[KOHA] Invoice paid: ${invoice.id} for subscription ${invoice.subscription}`);
// Create new donation record for this recurring payment
const subscription = await this.stripe.subscriptions.retrieve(invoice.subscription);
// Get currency from invoice or metadata
const currency = (invoice.currency || subscription.metadata.currency || 'NZD').toUpperCase();
const amount = invoice.amount_paid;
// Calculate NZD equivalent
const amountNZD = currency === 'NZD' ? amount : convertToNZD(amount, currency);
const exchangeRate = getExchangeRate(currency);
await Donation.create({
amount: amount,
currency: currency.toLowerCase(),
amount_nzd: amountNZD,
exchange_rate_to_nzd: exchangeRate,
frequency: 'monthly',
tier: subscription.metadata.tier,
donor: {
email: invoice.customer_email
},
public_acknowledgement: subscription.metadata.public_acknowledgement === 'yes',
stripe: {
customer_id: invoice.customer,
subscription_id: invoice.subscription,
invoice_id: invoice.id,
charge_id: invoice.charge
},
status: 'completed',
payment_date: new Date(invoice.created * 1000)
});
logger.info(`[KOHA] Recurring donation recorded: ${currency} $${amount / 100} (NZD $${amountNZD / 100})`);
} catch (error) {
logger.error('[KOHA] Error handling invoice paid:', error);
}
}
/**
* Handle failed invoice payment
*/
async handleInvoicePaymentFailed(invoice) {
try {
logger.warn(`[KOHA] Invoice payment failed: ${invoice.id}`);
// Could send notification email to donor here
} catch (error) {
logger.error('[KOHA] Error handling invoice payment failed:', error);
}
}
/**
* Handle subscription updates
*/
async handleSubscriptionUpdate(subscription) {
logger.info(`[KOHA] Subscription updated: ${subscription.id}, status: ${subscription.status}`);
}
/**
* Handle subscription cancellation
*/
async handleSubscriptionCancellation(subscription) {
try {
logger.info(`[KOHA] Subscription cancelled: ${subscription.id}`);
await Donation.cancelSubscription(subscription.id);
} catch (error) {
logger.error('[KOHA] Error handling subscription cancellation:', error);
}
}
/**
* Verify webhook signature
*/
verifyWebhookSignature(payload, signature) {
try {
return this.stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_KOHA_WEBHOOK_SECRET
);
} catch (error) {
logger.error('[KOHA] Webhook signature verification failed:', error);
throw new Error('Invalid webhook signature');
}
}
/**
* Get transparency metrics for public dashboard
*/
async getTransparencyMetrics() {
try {
return await Donation.getTransparencyMetrics();
} catch (error) {
logger.error('[KOHA] Error getting transparency metrics:', error);
throw error;
}
}
/**
* Send receipt email (placeholder)
*/
async sendReceiptEmail(donation) {
// TODO: Implement email service integration
logger.info(`[KOHA] Receipt email would be sent to ${donation.donor.email}`);
// Generate receipt number
const receiptNumber = `KOHA-${new Date().getFullYear()}-${String(donation._id).slice(-8).toUpperCase()}`;
await Donation.markReceiptSent(donation._id, receiptNumber);
return true;
}
/**
* Cancel a recurring donation (admin or donor-initiated)
*/
async cancelRecurringDonation(subscriptionId) {
try {
logger.info(`[KOHA] Cancelling subscription: ${subscriptionId}`);
// Cancel in Stripe
await this.stripe.subscriptions.cancel(subscriptionId);
// Update database
await Donation.cancelSubscription(subscriptionId);
return { success: true, message: 'Subscription cancelled successfully' };
} catch (error) {
logger.error('[KOHA] Error cancelling subscription:', error);
throw error;
}
}
/**
* Get donation statistics (admin only)
*/
async getStatistics(startDate = null, endDate = null) {
try {
return await Donation.getStatistics(startDate, endDate);
} catch (error) {
logger.error('[KOHA] Error getting statistics:', error);
throw error;
}
}
}
// Create singleton instance
const kohaService = new KohaService();
module.exports = kohaService;