diff --git a/docs/KOHA_STRIPE_SETUP.md b/docs/KOHA_STRIPE_SETUP.md index cb33804e..61b028b9 100644 --- a/docs/KOHA_STRIPE_SETUP.md +++ b/docs/KOHA_STRIPE_SETUP.md @@ -9,11 +9,12 @@ ## Overview -The Koha donation system uses the existing Stripe account from `passport-consolidated` to process donations in NZD (New Zealand Dollars). This document guides you through setting up the required Stripe products and webhooks. +The Koha donation system uses the existing Stripe account from `passport-consolidated` to process donations in multiple currencies. This document guides you through setting up the required Stripe products and webhooks. **Account:** Same Stripe test account as passport-consolidated -**Currency:** NZD (New Zealand Dollars) +**Currencies:** 10 supported (NZD base + USD, EUR, GBP, AUD, CAD, JPY, CHF, SGD, HKD) **Payment Types:** Recurring monthly subscriptions + one-time donations +**Multi-Currency:** Uses Stripe's `currency_options` feature for automatic conversion --- @@ -84,6 +85,71 @@ STRIPE_KOHA_50_PRICE_ID=price_ --- +## 2.5 Multi-Currency Setup with currency_options + +**IMPORTANT:** To support multiple currencies with a single price ID, configure `currency_options` for each monthly tier price. + +### Method 1: Stripe Dashboard (Recommended) + +1. Go to your product → Select a price (e.g., $5 NZD/month) +2. Click **Add currency** in the pricing section +3. Add each supported currency with converted amounts: + +| Base (NZD) | USD | EUR | GBP | AUD | CAD | JPY | CHF | SGD | HKD | +|------------|------|------|------|------|------|------|------|------|------| +| $5.00 | $3.00| €2.75| £2.35| $4.65| $4.10| ¥470 | 2.65 | $4.05| $23.40| +| $15.00 | $9.00| €8.25| £7.05|$13.95|$12.30|¥1410 | 7.95 |$12.15| $70.20| +| $50.00 |$30.00|€27.50|£23.50|$46.50|$41.00|¥4700 |26.50 |$40.50|$234.00| + +4. Repeat for all three monthly tier prices + +### Method 2: Stripe API + +Update existing prices via Stripe API: + +```bash +stripe prices update price_YOUR_5_NZD_PRICE_ID \ + --currency-options[usd][unit_amount]=300 \ + --currency-options[eur][unit_amount]=275 \ + --currency-options[gbp][unit_amount]=235 \ + --currency-options[aud][unit_amount]=465 \ + --currency-options[cad][unit_amount]=410 \ + --currency-options[jpy][unit_amount]=470 \ + --currency-options[chf][unit_amount]=265 \ + --currency-options[sgd][unit_amount]=405 \ + --currency-options[hkd][unit_amount]=2340 +``` + +Repeat for $15 and $50 tier prices with their corresponding amounts. + +### Exchange Rate Calculation + +Our base currency is NZD. Exchange rates (as of 2025-10-08): + +``` +1 NZD = 0.60 USD = 0.55 EUR = 0.47 GBP = 0.93 AUD = 0.82 CAD + = 94.0 JPY = 0.53 CHF = 0.81 SGD = 4.68 HKD +``` + +**Note:** Rates are configurable in `src/config/currencies.config.js`. Update periodically or integrate live exchange rate API. + +### One-Time Donations (Dynamic Currency) + +For one-time donations, the code dynamically creates Stripe checkout sessions with the selected currency: + +```javascript +sessionParams.line_items = [{ + price_data: { + currency: currentCurrency.toLowerCase(), // e.g., 'usd', 'eur' + unit_amount: amount // Amount in cents/smallest currency unit + } +}]; +``` + +No additional configuration needed - Stripe handles any supported currency. + +--- + ## 3. Webhook Configuration ### Create Webhook Endpoint @@ -223,11 +289,13 @@ FRONTEND_URL=http://localhost:9000 ### Test API Endpoint +**Test 1: Monthly donation in NZD** ```bash curl -X POST http://localhost:9000/api/koha/checkout \ -H "Content-Type: application/json" \ -d '{ "amount": 1500, + "currency": "NZD", "frequency": "monthly", "tier": "15", "donor": { @@ -238,6 +306,24 @@ curl -X POST http://localhost:9000/api/koha/checkout \ }' ``` +**Test 2: One-time donation in USD** +```bash +curl -X POST http://localhost:9000/api/koha/checkout \ + -H "Content-Type: application/json" \ + -d '{ + "amount": 2500, + "currency": "USD", + "frequency": "one_time", + "tier": "custom", + "donor": { + "name": "US Donor", + "email": "us@example.com" + }, + "public_acknowledgement": true, + "public_name": "Anonymous US Supporter" + }' +``` + **Expected Response:** ```json { @@ -317,10 +403,12 @@ Enable detailed Stripe logs: ```bash # In koha.service.js, logger.info statements will show: -[KOHA] Creating checkout session: monthly donation of NZD $15.00 +[KOHA] Creating checkout session: monthly donation of USD $9.00 (NZD $15.00) [KOHA] Checkout session created: cs_test_... [KOHA] Processing webhook event: checkout.session.completed -[KOHA] Donation recorded: NZD $15.00 +[KOHA] Checkout completed: monthly donation, tier: 15, currency: USD +[KOHA] Donation recorded: USD $9.00 (NZD $15.00) +[KOHA] Recurring donation recorded: EUR $8.25 (NZD $15.00) ``` --- diff --git a/public/js/components/currency-selector.js b/public/js/components/currency-selector.js new file mode 100644 index 00000000..b5a897b9 --- /dev/null +++ b/public/js/components/currency-selector.js @@ -0,0 +1,85 @@ +/** + * Currency Selector Component + * Dropdown for selecting donation currency + */ + +(function() { + 'use strict'; + + // Currency selector HTML + const selectorHTML = ` +
+ + +

+ Prices are automatically converted from NZD. Your selection is saved for future visits. +

+
+ `; + + // Initialize currency selector + function initCurrencySelector() { + // Find container (should have id="currency-selector-container") + const container = document.getElementById('currency-selector-container'); + if (!container) { + console.warn('Currency selector container not found'); + return; + } + + // Insert selector HTML + container.innerHTML = selectorHTML; + + // Get select element + const select = document.getElementById('currency-select'); + + // Set initial value from detected currency + const detectedCurrency = detectUserCurrency(); + select.value = detectedCurrency; + + // Trigger initial price update + if (typeof window.updatePricesForCurrency === 'function') { + window.updatePricesForCurrency(detectedCurrency); + } + + // Listen for changes + select.addEventListener('change', function(e) { + const newCurrency = e.target.value; + + // Save preference + saveCurrencyPreference(newCurrency); + + // Update prices + if (typeof window.updatePricesForCurrency === 'function') { + window.updatePricesForCurrency(newCurrency); + } + }); + } + + // Auto-initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initCurrencySelector); + } else { + initCurrencySelector(); + } + + // Expose init function globally + window.initCurrencySelector = initCurrencySelector; + +})(); diff --git a/public/js/components/footer.js b/public/js/components/footer.js new file mode 100644 index 00000000..7baac78e --- /dev/null +++ b/public/js/components/footer.js @@ -0,0 +1,95 @@ +/** + * Footer Component + * Shared footer for all Tractatus pages + */ + +(function() { + 'use strict'; + + // Create footer HTML + const footerHTML = ` + + `; + + // Insert footer at end of body + if (document.body) { + document.body.insertAdjacentHTML('beforeend', footerHTML); + } else { + // If body not ready, wait for DOM + document.addEventListener('DOMContentLoaded', function() { + document.body.insertAdjacentHTML('beforeend', footerHTML); + }); + } + +})(); diff --git a/public/js/utils/currency.js b/public/js/utils/currency.js new file mode 100644 index 00000000..081b81f7 --- /dev/null +++ b/public/js/utils/currency.js @@ -0,0 +1,131 @@ +/** + * Currency Utilities (Client-Side) + * Multi-currency support for Koha donation form + */ + +// Base prices in NZD (in cents) +const BASE_PRICES_NZD = { + tier_5: 500, // $5 NZD + tier_15: 1500, // $15 NZD + tier_50: 5000 // $50 NZD +}; + +// Exchange rates: 1 NZD = X currency +const EXCHANGE_RATES = { + NZD: 1.0, + USD: 0.60, + EUR: 0.55, + GBP: 0.47, + AUD: 0.93, + CAD: 0.82, + JPY: 94.0, + CHF: 0.53, + SGD: 0.81, + HKD: 4.68 +}; + +// Currency metadata +const CURRENCY_CONFIG = { + NZD: { symbol: '$', code: 'NZD', name: 'NZ Dollar', decimals: 2, flag: '🇳🇿' }, + USD: { symbol: '$', code: 'USD', name: 'US Dollar', decimals: 2, flag: '🇺🇸' }, + EUR: { symbol: '€', code: 'EUR', name: 'Euro', decimals: 2, flag: '🇪🇺' }, + GBP: { symbol: '£', code: 'GBP', name: 'British Pound', decimals: 2, flag: '🇬🇧' }, + AUD: { symbol: '$', code: 'AUD', name: 'Australian Dollar', decimals: 2, flag: '🇦🇺' }, + CAD: { symbol: '$', code: 'CAD', name: 'Canadian Dollar', decimals: 2, flag: '🇨🇦' }, + JPY: { symbol: '¥', code: 'JPY', name: 'Japanese Yen', decimals: 0, flag: '🇯🇵' }, + CHF: { symbol: 'CHF', code: 'CHF', name: 'Swiss Franc', decimals: 2, flag: '🇨🇭' }, + SGD: { symbol: '$', code: 'SGD', name: 'Singapore Dollar', decimals: 2, flag: '🇸🇬' }, + HKD: { symbol: '$', code: 'HKD', name: 'Hong Kong Dollar', decimals: 2, flag: '🇭🇰' } +}; + +// Supported currencies +const SUPPORTED_CURRENCIES = ['NZD', 'USD', 'EUR', 'GBP', 'AUD', 'CAD', 'JPY', 'CHF', 'SGD', 'HKD']; + +/** + * Convert NZD amount to target currency + */ +function convertFromNZD(amountNZD, targetCurrency) { + const rate = EXCHANGE_RATES[targetCurrency]; + return Math.round(amountNZD * rate); +} + +/** + * Get tier prices for a currency + */ +function getTierPrices(currency) { + return { + tier_5: convertFromNZD(BASE_PRICES_NZD.tier_5, currency), + tier_15: convertFromNZD(BASE_PRICES_NZD.tier_15, currency), + tier_50: convertFromNZD(BASE_PRICES_NZD.tier_50, currency) + }; +} + +/** + * Format currency amount for display + */ +function formatCurrency(amountCents, currency) { + const config = CURRENCY_CONFIG[currency]; + const amount = amountCents / 100; + + // For currencies with symbols that should come after (none in our list currently) + // we could customize here, but Intl.NumberFormat handles it well + + try { + return new Intl.NumberFormat('en-NZ', { + style: 'currency', + currency: currency, + minimumFractionDigits: config.decimals, + maximumFractionDigits: config.decimals + }).format(amount); + } catch (e) { + // Fallback if Intl fails + return `${config.symbol}${amount.toFixed(config.decimals)}`; + } +} + +/** + * Get currency display name with flag + */ +function getCurrencyDisplayName(currency) { + const config = CURRENCY_CONFIG[currency]; + return `${config.flag} ${config.code} - ${config.name}`; +} + +/** + * Detect user's currency from browser/location + */ +function detectUserCurrency() { + // Try localStorage first + const saved = localStorage.getItem('tractatus_currency'); + if (saved && SUPPORTED_CURRENCIES.includes(saved)) { + return saved; + } + + // Try to detect from browser language + const lang = navigator.language || navigator.userLanguage || 'en-NZ'; + const langMap = { + 'en-US': 'USD', + 'en-GB': 'GBP', + 'en-AU': 'AUD', + 'en-CA': 'CAD', + 'en-NZ': 'NZD', + 'ja': 'JPY', + 'ja-JP': 'JPY', + 'de': 'EUR', + 'de-DE': 'EUR', + 'fr': 'EUR', + 'fr-FR': 'EUR', + 'de-CH': 'CHF', + 'en-SG': 'SGD', + 'zh-HK': 'HKD' + }; + + return langMap[lang] || langMap[lang.substring(0, 2)] || 'NZD'; +} + +/** + * Save user's currency preference + */ +function saveCurrencyPreference(currency) { + localStorage.setItem('tractatus_currency', currency); +} diff --git a/public/koha.html b/public/koha.html index f71d4f13..eabfa259 100644 --- a/public/koha.html +++ b/public/koha.html @@ -73,6 +73,9 @@

+ +
+
@@ -298,9 +301,9 @@
-

Why NZD (New Zealand Dollars)?

+

What currencies do you accept?

- The Tractatus Framework is developed in Aotearoa New Zealand. Accepting NZD simplifies our operations and reflects our commitment to local context and indigenous partnership. + We accept 10 major currencies: NZD, USD, EUR, GBP, AUD, CAD, JPY, CHF, SGD, and HKD. Prices are automatically converted from our base currency (NZD) using current exchange rates. All donations are tracked in both your chosen currency and NZD for transparency reporting.

@@ -315,13 +318,52 @@ - + + + + + + + + + + +
+ + +
+

Privacy Policy

+

Last updated: October 8, 2025

+
+ + +
+

+ Privacy First: The Tractatus Framework is built on principles of human agency and transparency. We collect minimal data, never sell your information, and give you full control over your data. +

+
+ + +
+ + +
+

1. Information We Collect

+ +

1.1 Information You Provide

+
    +
  • Donations (Koha): Name (optional), email address (required for receipt), country (optional), payment information (processed by Stripe, not stored by us)
  • +
  • Media Inquiries: Name, email, organization, inquiry details
  • +
  • Case Submissions: Contact information, case description, supporting evidence
  • +
  • Account Creation (if applicable): Email, password (hashed), optional profile information
  • +
+ +

1.2 Automatically Collected Information

+
    +
  • Analytics: Page views, referring sites, browser type, device type, general location (country-level)
  • +
  • Cookies: Session management, preferences (e.g., selected currency), analytics
  • +
  • Server Logs: IP addresses, access times, pages accessed (retained for 90 days for security)
  • +
+ +

1.3 Currency Selection

+

+ When you select a currency for donations, we may detect your approximate location to suggest an appropriate currency. This location data is: +

+
    +
  • Derived from your IP address (country-level only, not precise geolocation)
  • +
  • Used only to pre-select a currency in the donation form
  • +
  • Not stored permanently
  • +
  • Can be overridden by manual currency selection
  • +
+
+ + +
+

2. How We Use Your Information

+ +
    +
  • Process Donations: Email receipts, acknowledge public supporters (opt-in only), maintain transparency dashboard
  • +
  • Respond to Inquiries: Answer media questions, review case submissions, provide support
  • +
  • Improve Services: Analyze usage patterns, fix bugs, enhance user experience
  • +
  • Security: Prevent fraud, detect abuse, protect against attacks
  • +
  • Legal Compliance: Comply with applicable laws, respond to legal requests
  • +
  • Communications: Send receipts, important updates (we never send marketing emails without explicit opt-in)
  • +
+
+ + +
+

3. Data Sharing and Disclosure

+ +

We Share Your Data With:

+ + +

We NEVER:

+
    +
  • ❌ Sell your personal data
  • +
  • ❌ Share your data with advertisers
  • +
  • ❌ Use your data for tracking across other websites
  • +
  • ❌ Share donor information publicly without explicit opt-in
  • +
+ +

Legal Disclosures:

+

+ We may disclose your information if required by law, court order, or to protect our rights and safety. We will notify you of such requests unless prohibited by law. +

+
+ + +
+

4. Data Retention

+ +
    +
  • Donation Records: Retained indefinitely for transparency and tax purposes
  • +
  • Server Logs: Deleted after 90 days
  • +
  • Analytics Data: Aggregated, anonymized after 12 months
  • +
  • User Accounts: Retained until you request deletion
  • +
  • Inquiries/Submissions: Retained for 2 years, then archived or deleted
  • +
+
+ + +
+

5. Your Rights

+ +

You have the right to:

+ +
    +
  • Access: Request a copy of your personal data
  • +
  • Correction: Update or correct inaccurate information
  • +
  • Deletion: Request deletion of your data (subject to legal obligations)
  • +
  • Portability: Receive your data in a machine-readable format
  • +
  • Opt-Out: Withdraw consent for public acknowledgements anytime
  • +
  • Object: Object to processing of your data
  • +
+ +

+ To exercise your rights, email: privacy@agenticgovernance.digital +

+
+ + +
+

6. Cookies and Tracking

+ +

Essential Cookies: Required for site functionality (session management, authentication)

+ +

Preference Cookies: Remember your settings (currency selection, theme preferences)

+ +

Analytics Cookies: Privacy-respecting analytics (no cross-site tracking)

+ +

+ You can control cookies through your browser settings. Disabling cookies may affect site functionality. +

+
+ + +
+

7. Security

+ +

We implement industry-standard security measures:

+ +
    +
  • HTTPS encryption for all connections
  • +
  • Encrypted database storage
  • +
  • Password hashing (bcrypt)
  • +
  • Regular security audits
  • +
  • Access controls and monitoring
  • +
  • No storage of payment card data (handled by Stripe PCI-compliant systems)
  • +
+ +

+ While we take reasonable precautions, no system is 100% secure. Report security issues to: security@agenticgovernance.digital +

+
+ + +
+

8. Children's Privacy

+ +

+ The Tractatus Framework is not directed at children under 13. We do not knowingly collect information from children. If you believe a child has provided us with personal data, please contact us at privacy@agenticgovernance.digital. +

+
+ + +
+

9. International Data Transfers

+ +

+ The Tractatus Framework operates from New Zealand. If you access our services from other countries, your data may be transferred to and processed in New Zealand. By using our services, you consent to this transfer. +

+ +

+ GDPR Compliance: For EU users, we comply with GDPR requirements including lawful basis for processing, data minimization, and your rights under Articles 15-22. +

+
+ + +
+

10. Changes to This Policy

+ +

+ We may update this Privacy Policy from time to time. Changes will be posted on this page with an updated "Last updated" date. Material changes will be communicated via email (for users who provided email) or prominent notice on the website. +

+
+ + +
+

11. Contact Us

+ +

For privacy-related questions or concerns:

+ +
+

Email: privacy@agenticgovernance.digital

+

Data Protection Officer: John Stroh

+

Postal Address: Available upon request

+
+
+ + +
+

Te Tiriti o Waitangi | Treaty Commitment

+

+ As a New Zealand-based project, we acknowledge Te Tiriti o Waitangi and our commitment to partnership, protection, and participation. Our privacy practices respect Māori concepts of data sovereignty (rangatiratanga) and collective guardianship (kaitiakitanga). +

+
+ +
+ +
+ + + + + + diff --git a/src/config/currencies.config.js b/src/config/currencies.config.js new file mode 100644 index 00000000..f8bae620 --- /dev/null +++ b/src/config/currencies.config.js @@ -0,0 +1,277 @@ +/** + * Currency Configuration + * Multi-currency support for Koha donation system + * + * Exchange rates based on NZD (New Zealand Dollar) as base currency + * Update rates periodically or use live API + */ + +// Base prices in NZD (in cents) +const BASE_PRICES_NZD = { + tier_5: 500, // $5 NZD + tier_15: 1500, // $15 NZD + tier_50: 5000 // $50 NZD +}; + +// Exchange rates: 1 NZD = X currency +// Last updated: 2025-10-08 +// Source: Manual calculation based on typical rates +const EXCHANGE_RATES = { + NZD: 1.0, // New Zealand Dollar (base) + USD: 0.60, // US Dollar + EUR: 0.55, // Euro + GBP: 0.47, // British Pound + AUD: 0.93, // Australian Dollar + CAD: 0.82, // Canadian Dollar + JPY: 94.0, // Japanese Yen + CHF: 0.53, // Swiss Franc + SGD: 0.81, // Singapore Dollar + HKD: 4.68 // Hong Kong Dollar +}; + +// Currency metadata (symbols, formatting, names) +const CURRENCY_CONFIG = { + NZD: { + symbol: '$', + code: 'NZD', + name: 'NZ Dollar', + decimals: 2, + locale: 'en-NZ', + flag: '🇳🇿' + }, + USD: { + symbol: '$', + code: 'USD', + name: 'US Dollar', + decimals: 2, + locale: 'en-US', + flag: '🇺🇸' + }, + EUR: { + symbol: '€', + code: 'EUR', + name: 'Euro', + decimals: 2, + locale: 'de-DE', + flag: '🇪🇺' + }, + GBP: { + symbol: '£', + code: 'GBP', + name: 'British Pound', + decimals: 2, + locale: 'en-GB', + flag: '🇬🇧' + }, + AUD: { + symbol: '$', + code: 'AUD', + name: 'Australian Dollar', + decimals: 2, + locale: 'en-AU', + flag: '🇦🇺' + }, + CAD: { + symbol: '$', + code: 'CAD', + name: 'Canadian Dollar', + decimals: 2, + locale: 'en-CA', + flag: '🇨🇦' + }, + JPY: { + symbol: '¥', + code: 'JPY', + name: 'Japanese Yen', + decimals: 0, // JPY has no decimal places + locale: 'ja-JP', + flag: '🇯🇵' + }, + CHF: { + symbol: 'CHF', + code: 'CHF', + name: 'Swiss Franc', + decimals: 2, + locale: 'de-CH', + flag: '🇨🇭' + }, + SGD: { + symbol: '$', + code: 'SGD', + name: 'Singapore Dollar', + decimals: 2, + locale: 'en-SG', + flag: '🇸🇬' + }, + HKD: { + symbol: '$', + code: 'HKD', + name: 'Hong Kong Dollar', + decimals: 2, + locale: 'zh-HK', + flag: '🇭🇰' + } +}; + +// Supported currencies list (in display order) +const SUPPORTED_CURRENCIES = [ + 'NZD', // Default + 'USD', + 'EUR', + 'GBP', + 'AUD', + 'CAD', + 'JPY', + 'CHF', + 'SGD', + 'HKD' +]; + +/** + * Convert NZD amount to target currency + * @param {number} amountNZD - Amount in NZD cents + * @param {string} targetCurrency - Target currency code + * @returns {number} - Amount in target currency cents + */ +function convertFromNZD(amountNZD, targetCurrency) { + const currency = targetCurrency.toUpperCase(); + + if (!EXCHANGE_RATES[currency]) { + throw new Error(`Unsupported currency: ${targetCurrency}`); + } + + const rate = EXCHANGE_RATES[currency]; + const converted = Math.round(amountNZD * rate); + + return converted; +} + +/** + * Convert any currency amount to NZD + * @param {number} amount - Amount in source currency cents + * @param {string} sourceCurrency - Source currency code + * @returns {number} - Amount in NZD cents + */ +function convertToNZD(amount, sourceCurrency) { + const currency = sourceCurrency.toUpperCase(); + + if (!EXCHANGE_RATES[currency]) { + throw new Error(`Unsupported currency: ${sourceCurrency}`); + } + + const rate = EXCHANGE_RATES[currency]; + const nzdAmount = Math.round(amount / rate); + + return nzdAmount; +} + +/** + * Get tier prices for a specific currency + * @param {string} currency - Currency code + * @returns {object} - Tier prices in target currency (cents) + */ +function getTierPrices(currency) { + const tier5 = convertFromNZD(BASE_PRICES_NZD.tier_5, currency); + const tier15 = convertFromNZD(BASE_PRICES_NZD.tier_15, currency); + const tier50 = convertFromNZD(BASE_PRICES_NZD.tier_50, currency); + + return { + tier_5: tier5, + tier_15: tier15, + tier_50: tier50 + }; +} + +/** + * Format currency amount for display + * @param {number} amountCents - Amount in cents + * @param {string} currency - Currency code + * @returns {string} - Formatted currency string (e.g., "$15.00", "¥1,400") + */ +function formatCurrency(amountCents, currency) { + const config = CURRENCY_CONFIG[currency.toUpperCase()]; + + if (!config) { + throw new Error(`Unsupported currency: ${currency}`); + } + + const amount = amountCents / 100; // Convert cents to dollars + + return new Intl.NumberFormat(config.locale, { + style: 'currency', + currency: currency.toUpperCase(), + minimumFractionDigits: config.decimals, + maximumFractionDigits: config.decimals + }).format(amount); +} + +/** + * Get currency display name with flag + * @param {string} currency - Currency code + * @returns {string} - Display name (e.g., "🇺🇸 USD - US Dollar") + */ +function getCurrencyDisplayName(currency) { + const config = CURRENCY_CONFIG[currency.toUpperCase()]; + + if (!config) { + return currency.toUpperCase(); + } + + return `${config.flag} ${config.code} - ${config.name}`; +} + +/** + * Validate currency code + * @param {string} currency - Currency code + * @returns {boolean} - True if supported + */ +function isSupportedCurrency(currency) { + return SUPPORTED_CURRENCIES.includes(currency.toUpperCase()); +} + +/** + * Get exchange rate for a currency + * @param {string} currency - Currency code + * @returns {number} - Exchange rate (1 NZD = X currency) + */ +function getExchangeRate(currency) { + return EXCHANGE_RATES[currency.toUpperCase()] || null; +} + +/** + * Detect currency from user location + * This is a simplified version - in production, use IP geolocation API + * @param {string} countryCode - ISO country code (e.g., 'US', 'GB') + * @returns {string} - Suggested currency code + */ +function getCurrencyForCountry(countryCode) { + const countryToCurrency = { + 'NZ': 'NZD', + 'US': 'USD', + 'DE': 'EUR', 'FR': 'EUR', 'IT': 'EUR', 'ES': 'EUR', 'NL': 'EUR', + 'GB': 'GBP', + 'AU': 'AUD', + 'CA': 'CAD', + 'JP': 'JPY', + 'CH': 'CHF', + 'SG': 'SGD', + 'HK': 'HKD' + }; + + return countryToCurrency[countryCode.toUpperCase()] || 'NZD'; // Default to NZD +} + +module.exports = { + BASE_PRICES_NZD, + EXCHANGE_RATES, + CURRENCY_CONFIG, + SUPPORTED_CURRENCIES, + convertFromNZD, + convertToNZD, + getTierPrices, + formatCurrency, + getCurrencyDisplayName, + isSupportedCurrency, + getExchangeRate, + getCurrencyForCountry +}; diff --git a/src/models/Donation.model.js b/src/models/Donation.model.js index b7833754..a2f955e6 100644 --- a/src/models/Donation.model.js +++ b/src/models/Donation.model.js @@ -21,8 +21,10 @@ class Donation { const donation = { // Donation details - amount: data.amount, // In cents (NZD) + amount: data.amount, // In cents (in the specified currency) currency: data.currency || 'nzd', + amount_nzd: data.amount_nzd || data.amount, // Amount in NZD for transparency calculations + exchange_rate_to_nzd: data.exchange_rate_to_nzd || 1.0, // Exchange rate at donation time frequency: data.frequency, // 'monthly' or 'one_time' tier: data.tier, // '5', '15', '50', or 'custom' @@ -180,8 +182,12 @@ class Donation { status: 'completed' }).toArray(); - // Calculate totals - const totalReceived = completedDonations.reduce((sum, d) => sum + d.amount, 0) / 100; // Convert to NZD + // Calculate totals (convert all to NZD for consistent totals) + const totalReceived = completedDonations.reduce((sum, d) => { + // Use amount_nzd if available, otherwise use amount (backwards compatibility) + const nzdAmount = d.amount_nzd || d.amount; + return sum + nzdAmount; + }, 0) / 100; // Convert cents to dollars // Count monthly supporters (active subscriptions) const monthlyDonations = completedDonations.filter(d => d.frequency === 'monthly'); @@ -199,12 +205,17 @@ class Donation { .map(d => ({ name: d.public_name, amount: d.amount / 100, + currency: d.currency || 'nzd', + amount_nzd: (d.amount_nzd || 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; + // Calculate monthly recurring revenue (in NZD) + const monthlyRevenue = activeSubscriptions.reduce((sum, d) => { + const nzdAmount = d.amount_nzd || d.amount; + return sum + nzdAmount; + }, 0) / 100; // Allocation breakdown (as per specification) const allocation = { diff --git a/src/services/koha.service.js b/src/services/koha.service.js index 94fd1679..39e88da1 100644 --- a/src/services/koha.service.js +++ b/src/services/koha.service.js @@ -3,12 +3,23 @@ * Donation processing service for Tractatus Framework * * Based on passport-consolidated's StripeService pattern - * Handles NZD donations via Stripe (reusing existing account) + * 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 logger = require('../utils/logger'); +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() { @@ -30,11 +41,17 @@ class KohaService { */ async createCheckoutSession(donationData) { try { - const { amount, frequency, tier, donor, public_acknowledgement, public_name } = donationData; + 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 NZD $1.00'); + throw new Error('Minimum donation amount is $1.00'); } if (!frequency || !['monthly', 'one_time'].includes(frequency)) { @@ -45,7 +62,11 @@ class KohaService { throw new Error('Donor email is required for receipt'); } - logger.info(`[KOHA] Creating checkout session: ${frequency} donation of NZD $${amount / 100}`); + // 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; @@ -85,6 +106,9 @@ class KohaService { 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 || '', @@ -117,7 +141,7 @@ class KohaService { // One-time payment mode - use custom amount sessionParams.line_items = [{ price_data: { - currency: 'nzd', + currency: donationCurrency.toLowerCase(), product_data: { name: 'Tractatus Framework Support', description: 'One-time donation to support the Tractatus Framework for AI safety', @@ -144,7 +168,9 @@ class KohaService { // Create pending donation record in database await Donation.create({ amount: amount, - currency: 'nzd', + currency: donationCurrency.toLowerCase(), + amount_nzd: amountNZD, + exchange_rate_to_nzd: exchangeRate, frequency: frequency, tier: tier, donor: { @@ -234,8 +260,11 @@ class KohaService { 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}`); + 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); @@ -244,7 +273,9 @@ class KohaService { // Create donation record from session data donation = await Donation.create({ amount: session.amount_total, - currency: session.currency, + currency: currency.toLowerCase(), + amount_nzd: amountNZD, + exchange_rate_to_nzd: exchangeRate, frequency: frequency, tier: tier, donor: { @@ -275,7 +306,7 @@ class KohaService { logger.error('[KOHA] Failed to send receipt email:', err) ); - logger.info(`[KOHA] Donation recorded: NZD $${session.amount_total / 100}`); + logger.info(`[KOHA] Donation recorded: ${currency} $${session.amount_total / 100} (NZD $${amountNZD / 100})`); } catch (error) { logger.error('[KOHA] Error handling checkout completion:', error); @@ -331,9 +362,19 @@ class KohaService { // 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: invoice.amount_paid, - currency: invoice.currency, + amount: amount, + currency: currency.toLowerCase(), + amount_nzd: amountNZD, + exchange_rate_to_nzd: exchangeRate, frequency: 'monthly', tier: subscription.metadata.tier, donor: { @@ -350,7 +391,7 @@ class KohaService { payment_date: new Date(invoice.created * 1000) }); - logger.info(`[KOHA] Recurring donation recorded: NZD $${invoice.amount_paid / 100}`); + logger.info(`[KOHA] Recurring donation recorded: ${currency} $${amount / 100} (NZD $${amountNZD / 100})`); } catch (error) { logger.error('[KOHA] Error handling invoice paid:', error);