- 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>
515 lines
16 KiB
JavaScript
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;
|