feat: integer cents money handling, order page fixes, and vendor filter persistence

Money Handling Architecture:
- Store all monetary values as integer cents (€105.91 = 10591)
- Add app/utils/money.py with Money class and conversion helpers
- Add static/shared/js/money.js for frontend formatting
- Update all database models to use _cents columns (Product, Order, etc.)
- Update CSV processor to convert prices to cents on import
- Add Alembic migration for Float to Integer conversion
- Create .architecture-rules/money.yaml with 7 validation rules
- Add docs/architecture/money-handling.md documentation

Order Details Page Fixes:
- Fix customer name showing 'undefined undefined' - use flat field names
- Fix vendor info empty - add vendor_name/vendor_code to OrderDetailResponse
- Fix shipping address using wrong nested object structure
- Enrich order detail API response with vendor info

Vendor Filter Persistence Fixes:
- Fix orders.js: restoreSavedVendor now sets selectedVendor and filters
- Fix orders.js: init() only loads orders if no saved vendor to restore
- Fix marketplace-letzshop.js: restoreSavedVendor calls selectVendor()
- Fix marketplace-letzshop.js: clearVendorSelection clears TomSelect dropdown
- Align vendor selector placeholder text between pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-20 20:33:48 +01:00
parent 7f0d32c18d
commit a19c84ea4e
56 changed files with 6155 additions and 447 deletions

View File

@@ -79,10 +79,16 @@ function adminMarketplaceLetzshop() {
api_key: '',
auto_sync_enabled: false,
sync_interval_minutes: 15,
test_mode_enabled: false,
letzshop_csv_url_fr: '',
letzshop_csv_url_en: '',
letzshop_csv_url_de: ''
letzshop_csv_url_de: '',
default_carrier: '',
carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
},
savingCarrierSettings: false,
// Orders
orders: [],
@@ -137,9 +143,75 @@ function adminMarketplaceLetzshop() {
this.initTomSelect();
});
// Check localStorage for last selected vendor
const savedVendorId = localStorage.getItem('letzshop_selected_vendor_id');
if (savedVendorId) {
marketplaceLetzshopLog.info('Restoring saved vendor:', savedVendorId);
// Load saved vendor after TomSelect is ready
setTimeout(async () => {
await this.restoreSavedVendor(parseInt(savedVendorId));
}, 200);
} else {
// Load cross-vendor data when no vendor selected
await this.loadCrossVendorData();
}
marketplaceLetzshopLog.info('Initialization complete');
},
/**
* Restore previously selected vendor from localStorage
*/
async restoreSavedVendor(vendorId) {
try {
// Load vendor details first
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
// Add to TomSelect and select (silent to avoid double-triggering)
if (this.tomSelectInstance) {
this.tomSelectInstance.addOption({
id: vendor.id,
name: vendor.name,
vendor_code: vendor.vendor_code
});
this.tomSelectInstance.setValue(vendor.id, true);
}
// Manually call selectVendor since we used silent mode above
// This sets selectedVendor and loads all vendor-specific data
await this.selectVendor(vendor.id);
marketplaceLetzshopLog.info('Restored saved vendor:', vendor.name);
} catch (error) {
marketplaceLetzshopLog.error('Failed to restore saved vendor:', error);
// Clear invalid saved vendor
localStorage.removeItem('letzshop_selected_vendor_id');
// Load cross-vendor data instead
await this.loadCrossVendorData();
}
},
/**
* Load cross-vendor aggregate data
*/
async loadCrossVendorData() {
marketplaceLetzshopLog.info('Loading cross-vendor data');
this.loading = true;
try {
await Promise.all([
this.loadOrders(),
this.loadExceptions(),
this.loadExceptionStats(),
this.loadJobs()
]);
} catch (error) {
marketplaceLetzshopLog.error('Failed to load cross-vendor data:', error);
} finally {
this.loading = false;
}
},
/**
* Initialize Tom Select for vendor autocomplete
*/
@@ -217,6 +289,9 @@ function adminMarketplaceLetzshop() {
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
this.selectedVendor = vendor;
// Save to localStorage for persistence
localStorage.setItem('letzshop_selected_vendor_id', vendorId.toString());
// Pre-fill settings form with CSV URLs
this.settingsForm.letzshop_csv_url_fr = vendor.letzshop_csv_url_fr || '';
this.settingsForm.letzshop_csv_url_en = vendor.letzshop_csv_url_en || '';
@@ -245,27 +320,39 @@ function adminMarketplaceLetzshop() {
/**
* Clear vendor selection
*/
clearVendorSelection() {
async clearVendorSelection() {
// Clear TomSelect dropdown
if (this.tomSelectInstance) {
this.tomSelectInstance.clear();
}
this.selectedVendor = null;
this.letzshopStatus = { is_configured: false };
this.credentials = null;
this.orders = [];
this.ordersFilter = '';
this.ordersSearch = '';
this.ordersHasDeclinedItems = false;
this.exceptions = [];
this.exceptionsFilter = '';
this.exceptionsSearch = '';
this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
this.jobs = [];
this.settingsForm = {
api_key: '',
auto_sync_enabled: false,
sync_interval_minutes: 15,
test_mode_enabled: false,
letzshop_csv_url_fr: '',
letzshop_csv_url_en: '',
letzshop_csv_url_de: ''
letzshop_csv_url_de: '',
default_carrier: '',
carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
};
// Clear localStorage
localStorage.removeItem('letzshop_selected_vendor_id');
// Load cross-vendor data
await this.loadCrossVendorData();
},
/**
@@ -285,6 +372,11 @@ function adminMarketplaceLetzshop() {
};
this.settingsForm.auto_sync_enabled = response.auto_sync_enabled;
this.settingsForm.sync_interval_minutes = response.sync_interval_minutes || 15;
this.settingsForm.test_mode_enabled = response.test_mode_enabled || false;
this.settingsForm.default_carrier = response.default_carrier || '';
this.settingsForm.carrier_greco_label_url = response.carrier_greco_label_url || 'https://dispatchweb.fr/Tracky/Home/';
this.settingsForm.carrier_colissimo_label_url = response.carrier_colissimo_label_url || '';
this.settingsForm.carrier_xpresslogistics_label_url = response.carrier_xpresslogistics_label_url || '';
} catch (error) {
if (error.status === 404) {
// Not configured
@@ -403,15 +495,9 @@ function adminMarketplaceLetzshop() {
// ═══════════════════════════════════════════════════════════════
/**
* Load orders for selected vendor
* Load orders for selected vendor (or all vendors if none selected)
*/
async loadOrders() {
if (!this.selectedVendor || !this.letzshopStatus.is_configured) {
this.orders = [];
this.totalOrders = 0;
return;
}
this.loadingOrders = true;
this.error = '';
@@ -433,7 +519,13 @@ function adminMarketplaceLetzshop() {
params.append('search', this.ordersSearch);
}
const response = await apiClient.get(`/admin/letzshop/vendors/${this.selectedVendor.id}/orders?${params}`);
// Use cross-vendor endpoint (with optional vendor_id filter)
let url = '/admin/letzshop/orders';
if (this.selectedVendor) {
params.append('vendor_id', this.selectedVendor.id.toString());
}
const response = await apiClient.get(`${url}?${params}`);
this.orders = response.orders || [];
this.totalOrders = response.total || 0;
@@ -845,7 +937,8 @@ function adminMarketplaceLetzshop() {
try {
const payload = {
auto_sync_enabled: this.settingsForm.auto_sync_enabled,
sync_interval_minutes: parseInt(this.settingsForm.sync_interval_minutes)
sync_interval_minutes: parseInt(this.settingsForm.sync_interval_minutes),
test_mode_enabled: this.settingsForm.test_mode_enabled
};
// Only include API key if it was provided (not just placeholder)
@@ -950,20 +1043,41 @@ function adminMarketplaceLetzshop() {
}
},
/**
* Save carrier settings
*/
async saveCarrierSettings() {
if (!this.selectedVendor || !this.credentials) return;
this.savingCarrierSettings = true;
this.error = '';
this.successMessage = '';
try {
await apiClient.patch(`/admin/letzshop/vendors/${this.selectedVendor.id}/credentials`, {
default_carrier: this.settingsForm.default_carrier || null,
carrier_greco_label_url: this.settingsForm.carrier_greco_label_url || null,
carrier_colissimo_label_url: this.settingsForm.carrier_colissimo_label_url || null,
carrier_xpresslogistics_label_url: this.settingsForm.carrier_xpresslogistics_label_url || null
});
this.successMessage = 'Carrier settings saved successfully';
} catch (error) {
marketplaceLetzshopLog.error('Failed to save carrier settings:', error);
this.error = error.message || 'Failed to save carrier settings';
} finally {
this.savingCarrierSettings = false;
}
},
// ═══════════════════════════════════════════════════════════════
// EXCEPTIONS
// ═══════════════════════════════════════════════════════════════
/**
* Load exceptions for selected vendor
* Load exceptions for selected vendor (or all vendors if none selected)
*/
async loadExceptions() {
if (!this.selectedVendor) {
this.exceptions = [];
this.totalExceptions = 0;
return;
}
this.loadingExceptions = true;
try {
@@ -980,7 +1094,12 @@ function adminMarketplaceLetzshop() {
params.append('search', this.exceptionsSearch);
}
const response = await apiClient.get(`/admin/order-exceptions?vendor_id=${this.selectedVendor.id}&${params}`);
// Add vendor filter if a vendor is selected
if (this.selectedVendor) {
params.append('vendor_id', this.selectedVendor.id.toString());
}
const response = await apiClient.get(`/admin/order-exceptions?${params}`);
this.exceptions = response.exceptions || [];
this.totalExceptions = response.total || 0;
} catch (error) {
@@ -992,19 +1111,20 @@ function adminMarketplaceLetzshop() {
},
/**
* Load exception statistics for selected vendor
* Load exception statistics for selected vendor (or all vendors if none selected)
*/
async loadExceptionStats() {
if (!this.selectedVendor) {
this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
return;
}
try {
const response = await apiClient.get(`/admin/order-exceptions/stats?vendor_id=${this.selectedVendor.id}`);
const params = new URLSearchParams();
if (this.selectedVendor) {
params.append('vendor_id', this.selectedVendor.id.toString());
}
const response = await apiClient.get(`/admin/order-exceptions/stats?${params}`);
this.exceptionStats = response;
} catch (error) {
marketplaceLetzshopLog.error('Failed to load exception stats:', error);
this.exceptionStats = { pending: 0, resolved: 0, ignored: 0, total: 0, orders_with_exceptions: 0 };
}
},
@@ -1119,10 +1239,13 @@ function adminMarketplaceLetzshop() {
/**
* Load jobs for selected vendor
* Note: Jobs are vendor-specific, so we need a vendor selected to show them
*/
async loadJobs() {
// Jobs require a vendor to be selected (they are vendor-specific)
if (!this.selectedVendor) {
this.jobs = [];
this.jobsPagination.total = 0;
return;
}

View File

@@ -76,6 +76,15 @@ function adminOrders() {
reason: ''
},
// Mark as shipped modal
showMarkAsShippedModal: false,
markingAsShipped: false,
shipForm: {
tracking_number: '',
tracking_url: '',
shipping_carrier: ''
},
// Debounce timer
searchTimeout: null,
@@ -137,16 +146,64 @@ function adminOrders() {
// Initialize Tom Select for vendor filter
this.initVendorSelect();
// Load data in parallel
await Promise.all([
this.loadStats(),
this.loadVendors(),
this.loadOrders()
]);
// Check localStorage for saved vendor
const savedVendorId = localStorage.getItem('orders_selected_vendor_id');
if (savedVendorId) {
adminOrdersLog.info('Restoring saved vendor:', savedVendorId);
// Restore vendor after a short delay to ensure TomSelect is ready
// restoreSavedVendor will call loadOrders() after setting the filter
setTimeout(async () => {
await this.restoreSavedVendor(parseInt(savedVendorId));
}, 200);
// Load stats and vendors, but not orders (restoreSavedVendor will do that)
await Promise.all([
this.loadStats(),
this.loadVendors()
]);
} else {
// No saved vendor - load all data including unfiltered orders
await Promise.all([
this.loadStats(),
this.loadVendors(),
this.loadOrders()
]);
}
adminOrdersLog.info('Orders initialization complete');
},
/**
* Restore saved vendor from localStorage
*/
async restoreSavedVendor(vendorId) {
try {
const vendor = await apiClient.get(`/admin/vendors/${vendorId}`);
if (this.vendorSelectInstance && vendor) {
// Add the vendor as an option and select it
this.vendorSelectInstance.addOption({
id: vendor.id,
name: vendor.name,
vendor_code: vendor.vendor_code
});
this.vendorSelectInstance.setValue(vendor.id, true);
// Set the filter state (this is the key fix!)
this.selectedVendor = vendor;
this.filters.vendor_id = vendor.id;
adminOrdersLog.info('Restored vendor:', vendor.name);
// Load orders with the vendor filter applied
await this.loadOrders();
}
} catch (error) {
adminOrdersLog.warn('Failed to restore saved vendor, clearing localStorage:', error);
localStorage.removeItem('orders_selected_vendor_id');
// Load unfiltered orders as fallback
await this.loadOrders();
}
},
/**
* Initialize Tom Select for vendor autocomplete
*/
@@ -168,7 +225,7 @@ function adminOrders() {
valueField: 'id',
labelField: 'name',
searchField: ['name', 'vendor_code'],
placeholder: 'All vendors...',
placeholder: 'Search vendor by name or code...',
allowEmptyOption: true,
load: async (query, callback) => {
try {
@@ -198,9 +255,13 @@ function adminOrders() {
const vendor = this.vendorSelectInstance.options[value];
this.selectedVendor = vendor;
this.filters.vendor_id = value;
// Save to localStorage
localStorage.setItem('orders_selected_vendor_id', value.toString());
} else {
this.selectedVendor = null;
this.filters.vendor_id = '';
// Clear from localStorage
localStorage.removeItem('orders_selected_vendor_id');
}
this.pagination.page = 1;
this.loadOrders();
@@ -219,6 +280,8 @@ function adminOrders() {
}
this.selectedVendor = null;
this.filters.vendor_id = '';
// Clear from localStorage
localStorage.removeItem('orders_selected_vendor_id');
this.pagination.page = 1;
this.loadOrders();
},
@@ -378,6 +441,76 @@ function adminOrders() {
}
},
/**
* Open mark as shipped modal
*/
openMarkAsShippedModal(order) {
this.selectedOrder = order;
this.shipForm = {
tracking_number: order.tracking_number || '',
tracking_url: order.tracking_url || '',
shipping_carrier: order.shipping_carrier || ''
};
this.showMarkAsShippedModal = true;
},
/**
* Mark order as shipped
*/
async markAsShipped() {
if (!this.selectedOrder) return;
this.markingAsShipped = true;
try {
const payload = {};
if (this.shipForm.tracking_number) {
payload.tracking_number = this.shipForm.tracking_number;
}
if (this.shipForm.tracking_url) {
payload.tracking_url = this.shipForm.tracking_url;
}
if (this.shipForm.shipping_carrier) {
payload.shipping_carrier = this.shipForm.shipping_carrier;
}
await apiClient.post(`/admin/orders/${this.selectedOrder.id}/ship`, payload);
adminOrdersLog.info('Marked order as shipped:', this.selectedOrder.id);
this.showMarkAsShippedModal = false;
this.selectedOrder = null;
Utils.showToast('Order marked as shipped successfully.', 'success');
await this.refresh();
} catch (error) {
adminOrdersLog.error('Failed to mark order as shipped:', error);
Utils.showToast(error.message || 'Failed to mark as shipped.', 'error');
} finally {
this.markingAsShipped = false;
}
},
/**
* Download shipping label for an order
*/
async downloadShippingLabel(order) {
try {
const labelInfo = await apiClient.get(`/admin/orders/${order.id}/shipping-label`);
if (labelInfo.label_url) {
// Open label URL in new tab
window.open(labelInfo.label_url, '_blank');
} else {
Utils.showToast('No shipping label URL available for this order.', 'warning');
}
} catch (error) {
adminOrdersLog.error('Failed to get shipping label:', error);
Utils.showToast(error.message || 'Failed to get shipping label.', 'error');
}
},
/**
* Get CSS class for status badge
*/

View File

@@ -30,11 +30,19 @@ function adminSettings() {
in_app_enabled: true,
critical_only: false
},
shippingSettings: {
carrier_greco_label_url: 'https://dispatchweb.fr/Tracky/Home/',
carrier_colissimo_label_url: '',
carrier_xpresslogistics_label_url: ''
},
async init() {
try {
settingsLog.info('=== SETTINGS PAGE INITIALIZING ===');
await this.loadLogSettings();
await Promise.all([
this.loadLogSettings(),
this.loadShippingSettings()
]);
} catch (error) {
settingsLog.error('Init failed:', error);
this.error = 'Failed to initialize settings page';
@@ -44,7 +52,10 @@ function adminSettings() {
async refresh() {
this.error = null;
this.successMessage = null;
await this.loadLogSettings();
await Promise.all([
this.loadLogSettings(),
this.loadShippingSettings()
]);
},
async loadLogSettings() {
@@ -136,6 +147,75 @@ function adminSettings() {
} finally {
this.saving = false;
}
},
async loadShippingSettings() {
try {
// Load each carrier setting
const carriers = ['greco', 'colissimo', 'xpresslogistics'];
for (const carrier of carriers) {
try {
const key = `carrier_${carrier}_label_url`;
const data = await apiClient.get(`/admin/settings/${key}`);
if (data && data.value) {
this.shippingSettings[key] = data.value;
}
} catch (error) {
// Setting doesn't exist yet, use default
settingsLog.debug(`Setting carrier_${carrier}_label_url not found, using default`);
}
}
settingsLog.info('Shipping settings loaded:', this.shippingSettings);
} catch (error) {
settingsLog.error('Failed to load shipping settings:', error);
// Don't show error for missing settings, just use defaults
}
},
async saveShippingSettings() {
this.saving = true;
this.error = null;
this.successMessage = null;
try {
// Save each carrier setting using upsert
const carriers = [
{ key: 'carrier_greco_label_url', name: 'Greco' },
{ key: 'carrier_colissimo_label_url', name: 'Colissimo' },
{ key: 'carrier_xpresslogistics_label_url', name: 'XpressLogistics' }
];
for (const carrier of carriers) {
await apiClient.post('/admin/settings/upsert', {
key: carrier.key,
value: this.shippingSettings[carrier.key] || '',
category: 'shipping',
value_type: 'string',
description: `Label URL prefix for ${carrier.name} carrier`
});
}
this.successMessage = 'Shipping settings saved successfully';
// Auto-hide success message after 5 seconds
setTimeout(() => {
this.successMessage = null;
}, 5000);
settingsLog.info('Shipping settings saved:', this.shippingSettings);
} catch (error) {
settingsLog.error('Failed to save shipping settings:', error);
this.error = error.response?.data?.detail || 'Failed to save shipping settings';
} finally {
this.saving = false;
}
},
getShippingLabelUrl(carrier, shipmentNumber) {
// Helper to generate full label URL
const prefix = this.shippingSettings[`carrier_${carrier}_label_url`] || '';
if (!prefix || !shipmentNumber) return null;
return prefix + shipmentNumber;
}
};
}

193
static/shared/js/money.js Normal file
View File

@@ -0,0 +1,193 @@
// static/shared/js/money.js
/**
* Money handling utilities using integer cents.
*
* All monetary values are stored as integers representing cents in the database.
* The API returns euros (converted from cents on the backend), but these utilities
* can be used if cents are passed to the frontend.
*
* Example:
* 105.91 EUR is stored as 10591 (integer cents)
*
* Usage:
* Money.format(10591) // Returns "105.91"
* Money.format(10591, 'EUR') // Returns "105,91 EUR"
* Money.toCents(105.91) // Returns 10591
* Money.toEuros(10591) // Returns 105.91
* Money.formatEuros(105.91, 'EUR') // Returns "105,91 EUR"
*
* See docs/architecture/money-handling.md for full documentation.
*/
const Money = {
/**
* Format cents as a currency string.
*
* @param {number} cents - Amount in cents
* @param {string} currency - Currency code (default: '', no currency shown)
* @param {string} locale - Locale for formatting (default: 'de-DE')
* @returns {string} Formatted price string
*
* @example
* Money.format(10591) // "105.91"
* Money.format(10591, 'EUR') // "105,91 EUR" (German locale)
* Money.format(1999) // "19.99"
*/
format(cents, currency = '', locale = 'de-DE') {
if (cents === null || cents === undefined) {
cents = 0;
}
const euros = cents / 100;
if (currency) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(euros);
}
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(euros);
},
/**
* Convert euros to cents.
*
* @param {number|string} euros - Amount in euros
* @returns {number} Amount in cents (integer)
*
* @example
* Money.toCents(105.91) // 10591
* Money.toCents('19.99') // 1999
* Money.toCents(null) // 0
*/
toCents(euros) {
if (euros === null || euros === undefined || euros === '') {
return 0;
}
return Math.round(parseFloat(euros) * 100);
},
/**
* Convert cents to euros.
*
* @param {number} cents - Amount in cents
* @returns {number} Amount in euros
*
* @example
* Money.toEuros(10591) // 105.91
* Money.toEuros(1999) // 19.99
* Money.toEuros(null) // 0
*/
toEuros(cents) {
if (cents === null || cents === undefined) {
return 0;
}
return cents / 100;
},
/**
* Format a euro amount for display.
*
* Use this when the value is already in euros (e.g., from API response).
*
* @param {number} euros - Amount in euros
* @param {string} currency - Currency code (default: 'EUR')
* @param {string} locale - Locale for formatting (default: 'de-DE')
* @returns {string} Formatted price string
*
* @example
* Money.formatEuros(105.91, 'EUR') // "105,91 EUR"
* Money.formatEuros(19.99) // "19.99"
*/
formatEuros(euros, currency = '', locale = 'de-DE') {
if (euros === null || euros === undefined) {
euros = 0;
}
if (currency) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency
}).format(euros);
}
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(euros);
},
/**
* Parse a price string to cents.
*
* Handles various formats:
* - "19.99 EUR"
* - "19,99"
* - 19.99
*
* @param {string|number} priceStr - Price string or number
* @returns {number} Amount in cents
*
* @example
* Money.parse("19.99 EUR") // 1999
* Money.parse("19,99") // 1999
* Money.parse(19.99) // 1999
*/
parse(priceStr) {
if (priceStr === null || priceStr === undefined || priceStr === '') {
return 0;
}
if (typeof priceStr === 'number') {
return Math.round(priceStr * 100);
}
// Remove currency symbols and spaces
let cleaned = priceStr.toString().replace(/[^\d,.-]/g, '');
// Handle European decimal comma
cleaned = cleaned.replace(',', '.');
try {
return Math.round(parseFloat(cleaned) * 100);
} catch {
return 0;
}
},
/**
* Calculate line total (unit price * quantity).
*
* @param {number} unitPriceCents - Price per unit in cents
* @param {number} quantity - Number of units
* @returns {number} Total in cents
*/
calculateLineTotal(unitPriceCents, quantity) {
return unitPriceCents * quantity;
},
/**
* Calculate order total.
*
* @param {number} subtotalCents - Sum of line items in cents
* @param {number} taxCents - Tax amount in cents (default: 0)
* @param {number} shippingCents - Shipping cost in cents (default: 0)
* @param {number} discountCents - Discount amount in cents (default: 0)
* @returns {number} Total in cents
*/
calculateOrderTotal(subtotalCents, taxCents = 0, shippingCents = 0, discountCents = 0) {
return subtotalCents + taxCents + shippingCents - discountCents;
}
};
// Make available globally
window.Money = Money;
// Export for modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = Money;
}