Merge branch 'master' of git.freeside.biz:/home/git/freeside
authorIvan Kohler <ivan@freeside.biz>
Fri, 5 Oct 2012 03:25:37 +0000 (20:25 -0700)
committerIvan Kohler <ivan@freeside.biz>
Fri, 5 Oct 2012 03:25:37 +0000 (20:25 -0700)
407 files changed:
FS/FS.pm
FS/FS/AccessRight.pm
FS/FS/ClientAPI/MasonComponent.pm
FS/FS/ClientAPI/MyAccount.pm
FS/FS/Conf.pm
FS/FS/Cron/agent_email.pm [new file with mode: 0644]
FS/FS/Cron/pay_batch.pm
FS/FS/Mason.pm
FS/FS/Mason/Request.pm
FS/FS/Misc.pm
FS/FS/Record.pm
FS/FS/Report/FCC_477.pm
FS/FS/Schema.pm
FS/FS/TemplateItem_Mixin.pm [new file with mode: 0644]
FS/FS/Template_Mixin.pm
FS/FS/TicketSystem/RT_Internal.pm
FS/FS/Trace.pm [new file with mode: 0644]
FS/FS/Upgrade.pm
FS/FS/access_right.pm
FS/FS/addr_block.pm
FS/FS/agent_pkg_class.pm [new file with mode: 0644]
FS/FS/cdr.pm
FS/FS/cdr/taqua.pm
FS/FS/cdr/taqua62.pm [new file with mode: 0644]
FS/FS/cust_bill.pm
FS/FS/cust_bill_ApplicationCommon.pm
FS/FS/cust_bill_pkg.pm
FS/FS/cust_bill_pkg_detail_void.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg_discount.pm
FS/FS/cust_bill_pkg_discount_void.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg_display_void.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg_tax_location_void.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg_tax_rate_location_void.pm [new file with mode: 0644]
FS/FS/cust_bill_pkg_void.pm [new file with mode: 0644]
FS/FS/cust_bill_void.pm [new file with mode: 0644]
FS/FS/cust_credit_bill_pkg.pm
FS/FS/cust_main.pm
FS/FS/cust_main/Billing.pm
FS/FS/cust_main/Import.pm
FS/FS/cust_main/NationalID.pm [new file with mode: 0644]
FS/FS/cust_main/Search.pm
FS/FS/cust_main_county.pm
FS/FS/cust_pay.pm
FS/FS/cust_pkg.pm
FS/FS/cust_pkg_discount.pm
FS/FS/cust_svc.pm
FS/FS/cust_tax_exempt_pkg.pm
FS/FS/cust_tax_exempt_pkg_void.pm [new file with mode: 0644]
FS/FS/cust_tax_location.pm
FS/FS/detail_format/sum_duration_prefix.pm
FS/FS/discount.pm
FS/FS/h_cust_main_exemption.pm [new file with mode: 0644]
FS/FS/h_part_pkg.pm [new file with mode: 0644]
FS/FS/part_event.pm
FS/FS/part_event/Action/Mixin/credit_agent_pkg_class.pm [new file with mode: 0644]
FS/FS/part_event/Action/Mixin/credit_pkg.pm
FS/FS/part_event/Action/pkg_agent_credit.pm
FS/FS/part_event/Action/pkg_agent_credit_pkg_class.pm [new file with mode: 0644]
FS/FS/part_event/Condition/after_event.pm [new file with mode: 0644]
FS/FS/part_export.pm
FS/FS/part_export/acct_google.pm
FS/FS/part_export/acct_http.pm
FS/FS/part_export/acct_plesk.pm
FS/FS/part_export/acct_sql.pm
FS/FS/part_export/acct_sql_status.pm
FS/FS/part_export/acct_xmlrpc.pm
FS/FS/part_export/amazon_ec2.pm
FS/FS/part_export/artera_turbo.pm
FS/FS/part_export/broadband_http.pm
FS/FS/part_export/broadband_nas.pm
FS/FS/part_export/broadband_shellcommands.pm
FS/FS/part_export/broadband_snmp.pm
FS/FS/part_export/broadband_sql.pm
FS/FS/part_export/broadband_sqlradius.pm
FS/FS/part_export/communigate_pro.pm
FS/FS/part_export/communigate_pro_singledomain.pm
FS/FS/part_export/cp.pm
FS/FS/part_export/cpanel.pm
FS/FS/part_export/cust_http.pm
FS/FS/part_export/cyrus.pm
FS/FS/part_export/dashcs_e911.pm
FS/FS/part_export/dma_radiusmanager.pm [new file with mode: 0644]
FS/FS/part_export/domain_sql.pm
FS/FS/part_export/everyone_net.pm
FS/FS/part_export/ez_prepaid.pm [new file with mode: 0644]
FS/FS/part_export/forward_sql.pm
FS/FS/part_export/freeswitch.pm [new file with mode: 0644]
FS/FS/part_export/globalpops_voip.pm
FS/FS/part_export/http.pm
FS/FS/part_export/http_status.pm
FS/FS/part_export/ikano.pm
FS/FS/part_export/indosoft.pm
FS/FS/part_export/infostreet.pm
FS/FS/part_export/internal_diddb.pm
FS/FS/part_export/ldap.pm
FS/FS/part_export/netsapiens.pm
FS/FS/part_export/null.pm
FS/FS/part_export/phone_shellcommands.pm
FS/FS/part_export/phone_sqlopensips.pm
FS/FS/part_export/phone_sqlradius.pm
FS/FS/part_export/postfix.pm
FS/FS/part_export/prizm.pm
FS/FS/part_export/radiator.pm
FS/FS/part_export/router.pm
FS/FS/part_export/rt_ticket.pm
FS/FS/part_export/send_email.pm
FS/FS/part_export/shellcommands.pm
FS/FS/part_export/shellcommands_withdomain.pm
FS/FS/part_export/sqlmail.pm
FS/FS/part_export/sqlradius.pm
FS/FS/part_export/sqlradius_withdomain.pm
FS/FS/part_export/textradius.pm
FS/FS/part_export/trango.pm
FS/FS/part_export/vitelity.pm
FS/FS/part_export/vpopmail.pm
FS/FS/part_export/www_plesk.pm
FS/FS/part_export/www_shellcommands.pm
FS/FS/part_export_machine.pm [new file with mode: 0644]
FS/FS/part_pkg.pm
FS/FS/part_pkg/delayed_Mixin.pm
FS/FS/part_pkg/prepaid.pm
FS/FS/part_pkg/prorate.pm
FS/FS/part_pkg/recur_Common.pm
FS/FS/part_pkg_taxrate.pm
FS/FS/part_svc.pm
FS/FS/pay_batch.pm
FS/FS/pay_batch/BoM.pm
FS/FS/pay_batch/td_eft1464.pm
FS/FS/quotation.pm
FS/FS/radius_group.pm
FS/FS/rate.pm
FS/FS/reason.pm
FS/FS/svc_Common.pm
FS/FS/svc_Tower_Mixin.pm
FS/FS/svc_acct.pm
FS/FS/svc_broadband.pm
FS/FS/svc_export_machine.pm [new file with mode: 0644]
FS/FS/tax_class.pm
FS/FS/tax_rate.pm
FS/FS/tax_rate_location.pm
FS/MANIFEST
FS/bin/freeside-cdrd
FS/bin/freeside-daily
FS/t/agent_pkg_class.t [new file with mode: 0644]
FS/t/cust_bill_pkg_detail_void.t [new file with mode: 0644]
FS/t/cust_bill_pkg_discount_void.t [new file with mode: 0644]
FS/t/cust_bill_pkg_display_void.t [new file with mode: 0644]
FS/t/cust_bill_pkg_tax_location_void.t [new file with mode: 0644]
FS/t/cust_bill_pkg_tax_rate_location_void.t [new file with mode: 0644]
FS/t/cust_bill_pkg_void.t [new file with mode: 0644]
FS/t/cust_bill_void.t [new file with mode: 0644]
FS/t/cust_tax_exempt_pkg_void.t [new file with mode: 0644]
FS/t/part_export_machine.t [new file with mode: 0644]
FS/t/svc_export_machine.t [new file with mode: 0644]
Makefile
bin/231commit
bin/23diff
bin/agent_email [new file with mode: 0755]
bin/cdr.import [changed mode: 0644->0755]
bin/cust_bill.export [new file with mode: 0755]
bin/cust_main-bill_now [changed mode: 0644->0755]
bin/cust_main.export [new file with mode: 0755]
bin/cust_pkg.export [new file with mode: 0755]
bin/pod2x
bin/svc_acct.export [new file with mode: 0755]
bin/svc_broadband.export [new file with mode: 0755]
bin/svc_phone.export [new file with mode: 0755]
bin/tax_location.upgrade [new file with mode: 0755]
bin/v-rate-reimport [new file with mode: 0755]
conf/invoice_latex
conf/quotation_latex
etc/fslongtable.sty [deleted file]
etc/longtable.sty [new file with mode: 0644]
fs_selfservice/DEPLOY
fs_selfservice/FS-SelfService/cgi/agent.cgi [changed mode: 0644->0755]
fs_selfservice/FS-SelfService/cgi/cust_bill-logo.cgi [changed mode: 0644->0755]
fs_selfservice/FS-SelfService/cgi/make_payment.html
fs_selfservice/FS-SelfService/cgi/selfservice.cgi [changed mode: 0644->0755]
fs_selfservice/FS-SelfService/cgi/xmlrpc.cgi [changed mode: 0644->0755]
htetc/handler.pl
httemplate/browse/agent.cgi
httemplate/browse/cust_note_class.html
httemplate/browse/part_export.cgi
httemplate/browse/part_svc.cgi
httemplate/browse/radius_group.html
httemplate/browse/reason.html
httemplate/config/config.cgi
httemplate/docs/license.html
httemplate/edit/agent.cgi
httemplate/edit/cust_main.cgi
httemplate/edit/cust_main/billing.html
httemplate/edit/cust_main/birthdate.html
httemplate/edit/cust_refund.cgi
httemplate/edit/discount.html
httemplate/edit/nas.html
httemplate/edit/part_export.cgi
httemplate/edit/part_pkg.cgi
httemplate/edit/part_svc.cgi
httemplate/edit/payment_gateway.html
httemplate/edit/prepay_credit.cgi
httemplate/edit/process/agent.cgi
httemplate/edit/process/cust_main.cgi
httemplate/edit/process/cust_pkg_discount.html
httemplate/edit/process/cust_refund.cgi
httemplate/edit/process/part_export.cgi
httemplate/edit/process/quick-cust_pkg.cgi
httemplate/edit/process/svc_acct.cgi
httemplate/edit/process/svc_broadband.cgi
httemplate/edit/radius_group.html
httemplate/edit/reason.html
httemplate/edit/svc_acct.cgi
httemplate/elements/dashboard-toplist.html
httemplate/elements/location.html
httemplate/elements/menu.html
httemplate/elements/select-rt-customfield.html
httemplate/elements/select-table.html
httemplate/elements/tr-amount_fee.html
httemplate/elements/tr-select-cust_location.html
httemplate/elements/tr-select-discount.html
httemplate/elements/tr-select-part_referral.html
httemplate/elements/tr-select-reason.html
httemplate/elements/tr-select-voip_class.html [new file with mode: 0644]
httemplate/elements/tr-svc_export_machine.html [new file with mode: 0644]
httemplate/graph/cust_bill_pkg.cgi
httemplate/graph/elements/monthly.html
httemplate/graph/elements/report.html
httemplate/graph/report_cust_bill_pkg.html
httemplate/index.html
httemplate/misc/cust_main-import.cgi
httemplate/misc/order_pkg.html
httemplate/misc/payment.cgi
httemplate/misc/process/void-cust_bill.html [new file with mode: 0755]
httemplate/misc/timeworked.html
httemplate/misc/unvoid-cust_bill_void.html [new file with mode: 0755]
httemplate/misc/unvoid-cust_pay_void.cgi
httemplate/misc/void-cust_bill.html [new file with mode: 0644]
httemplate/misc/void-cust_pay.cgi
httemplate/misc/xmlhttp-cust_main-search.cgi
httemplate/pref/pref-process.html
httemplate/pref/pref.html
httemplate/search/477.html
httemplate/search/477partIA_detail.html
httemplate/search/477partIA_summary.html
httemplate/search/477partIIA.html
httemplate/search/477partIIB.html
httemplate/search/477partV.html
httemplate/search/477partVI_census.html
httemplate/search/cust_bill_pkg.cgi
httemplate/search/cust_bill_pkg_referral.html
httemplate/search/cust_main-zip.html
httemplate/search/cust_main.cgi
httemplate/search/cust_main.html
httemplate/search/cust_pay_pending.html
httemplate/search/cust_tax_exempt_pkg.cgi
httemplate/search/elements/cust_pay_batch_top.html
httemplate/search/elements/search-csv.html
httemplate/search/elements/search-html.html
httemplate/search/elements/search-xls.html
httemplate/search/elements/search.html
httemplate/search/quotation.html [new file with mode: 0755]
httemplate/search/report_477.html
httemplate/search/report_cdr.html
httemplate/search/report_cust_bill_pkg_referral.html
httemplate/search/report_cust_main-zip.html
httemplate/search/report_cust_main.html
httemplate/search/report_quotation.html [new file with mode: 0644]
httemplate/search/report_rt_ticket.html
httemplate/search/report_sqlradius_usage.html [new file with mode: 0644]
httemplate/search/report_tax.cgi
httemplate/search/sqlradius_usage.html [new file with mode: 0644]
httemplate/view/cust_bill.cgi
httemplate/view/cust_bill_void.html [new file with mode: 0755]
httemplate/view/cust_main/billing.html
httemplate/view/cust_main/contacts.html
httemplate/view/cust_main/custom_content/.birthdate.html.swp [deleted file]
httemplate/view/cust_main/custom_content/.small_custview.html.swp [deleted file]
httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp [deleted file]
httemplate/view/cust_main/custom_content/.svc_Common.html.swp [deleted file]
httemplate/view/cust_main/custom_content/.svc_acct.html.swp [deleted file]
httemplate/view/cust_main/custom_content/.svc_hardware.html.swp [deleted file]
httemplate/view/cust_main/custom_content/.svc_phone.html.swp [deleted file]
httemplate/view/cust_main/misc.html
httemplate/view/cust_main/payment_history.html
httemplate/view/cust_main/payment_history/invoice.html
httemplate/view/cust_main/payment_history/payment.html
httemplate/view/cust_main/payment_history/voided_invoice.html [new file with mode: 0644]
httemplate/view/cust_main/payment_history/voided_payment.html
httemplate/view/elements/tr-svc_export_machine.html [new file with mode: 0644]
httemplate/view/quotation.html
httemplate/view/svc_acct/basics.html
init.d/insserv-override-apache2 [new file with mode: 0644]
rt/bin/rt
rt/bin/rt.in
rt/configure
rt/docs/web_deployment.pod
rt/etc/RT_Config.pm.in
rt/etc/RT_SiteConfig.pm
rt/etc/initialdata
rt/etc/schema.SQLite
rt/etc/upgrade/3.3.0/schema.mysql
rt/etc/upgrade/3.3.11/schema.mysql
rt/etc/upgrade/3.9.5/schema.mysql
rt/etc/upgrade/3.9.7/schema.mysql
rt/lib/RT.pm
rt/lib/RT.pm.in [deleted file]
rt/lib/RT/Action/CreateTickets.pm
rt/lib/RT/Articles.pm
rt/lib/RT/Config.pm
rt/lib/RT/Crypt/GnuPG.pm
rt/lib/RT/Dashboard.pm
rt/lib/RT/Generated.pm
rt/lib/RT/I18N.pm
rt/lib/RT/Interface/Email.pm
rt/lib/RT/Interface/Web.pm
rt/lib/RT/Record.pm
rt/lib/RT/Scrip.pm
rt/lib/RT/Scrips.pm
rt/lib/RT/Search/Googleish.pm
rt/lib/RT/SearchBuilder.pm
rt/lib/RT/Shredder.pm
rt/lib/RT/Test.pm
rt/lib/RT/Ticket.pm
rt/lib/RT/Tickets.pm
rt/lib/RT/Transaction.pm
rt/lib/RT/URI.pm
rt/lib/RT/User.pm
rt/sbin/rt-server.fcgi.in
rt/sbin/rt-server.in
rt/sbin/rt-test-dependencies.in
rt/sbin/standalone_httpd
rt/sbin/standalone_httpd.in
rt/share/html/Admin/Queues/Modify.html
rt/share/html/Approvals/Elements/PendingMyApproval
rt/share/html/Approvals/autohandler
rt/share/html/Dashboards/Subscription.html
rt/share/html/Elements/AddCustomers
rt/share/html/Elements/ColumnMap
rt/share/html/Elements/EditCustomField
rt/share/html/Elements/Header
rt/share/html/Elements/HeaderJavascript
rt/share/html/Elements/ListActions
rt/share/html/Elements/MessageBox
rt/share/html/Elements/QueueSummaryByStatus
rt/share/html/Elements/RT__CustomField/ColumnMap
rt/share/html/Elements/SelectWatcherType
rt/share/html/Elements/Tabs
rt/share/html/Helpers/Autocomplete/Users
rt/share/html/NoAuth/css/aileron/boxes.css
rt/share/html/NoAuth/css/aileron/ticket.css
rt/share/html/NoAuth/css/ballard/boxes.css
rt/share/html/NoAuth/css/ballard/layout.css
rt/share/html/NoAuth/css/ballard/nav.css
rt/share/html/NoAuth/css/ballard/ticket-search.css
rt/share/html/NoAuth/css/ballard/ticket.css
rt/share/html/NoAuth/css/base/forms.css
rt/share/html/NoAuth/css/base/jquery-ui-timepicker-addon.css [new file with mode: 0644]
rt/share/html/NoAuth/css/base/jquery-ui.css
rt/share/html/NoAuth/css/base/jquery-ui.custom.modified.css
rt/share/html/NoAuth/css/base/main.css
rt/share/html/NoAuth/css/base/superfish-navbar.css
rt/share/html/NoAuth/css/base/superfish.css
rt/share/html/NoAuth/css/base/ticket-form.css
rt/share/html/NoAuth/css/base/ui.timepickr.css [deleted file]
rt/share/html/NoAuth/css/base/ui.timepickr.custom.css [deleted file]
rt/share/html/NoAuth/css/web2/nav.css
rt/share/html/NoAuth/js/jquery-ui-1.8.4.custom.min.js
rt/share/html/NoAuth/js/jquery-ui-patch-datepicker.js
rt/share/html/NoAuth/js/jquery-ui-timepicker-addon.js [new file with mode: 0644]
rt/share/html/NoAuth/js/ui.timepickr.js [deleted file]
rt/share/html/NoAuth/js/util.js
rt/share/html/Prefs/Other.html
rt/share/html/REST/1.0/Forms/ticket/default
rt/share/html/Search/Chart.html
rt/share/html/Search/Elements/SelectPersonType
rt/share/html/Search/Results.html
rt/share/html/Search/Results.xls
rt/share/html/Ticket/Attachment/dhandler
rt/share/html/Ticket/Elements/AddCustomers
rt/share/html/Ticket/Elements/ShowMembers
rt/share/html/Ticket/Elements/ShowTransactionAttachments
rt/share/html/m/_elements/raw_style
rt/share/html/m/_elements/wrapper
rt/t/api/config.t
rt/t/api/template-insert.t [deleted file]
rt/t/api/template-simple.t [deleted file]
rt/t/api/template.t
rt/t/articles/search-interface.t
rt/t/articles/uri-a.t
rt/t/data/configs/apache2.2+fastcgi.conf.in
rt/t/data/configs/apache2.2+mod_perl.conf.in
rt/t/mail/dashboard-chart-with-utf8.t
rt/t/mail/dashboards.t
rt/t/mail/gateway.t
rt/t/shredder/01ticket.t
rt/t/shredder/03plugin_tickets.t
rt/t/shredder/03plugin_users.t
rt/t/shredder/utils.pl
rt/t/ticket/search_by_watcher.t
rt/t/web/attachments.t
rt/t/web/command_line.t
rt/t/web/command_line_with_unknown_field.t
rt/t/web/crypt-gnupg.t
rt/t/web/googleish_search.t
rt/t/web/query_builder_queue_limits.t
rt/t/web/search_simple.t
rt/t/web/ticket_modify_all.t
rt/t/web/transaction_batch.t

index 8bbff12..2d963b5 100644 (file)
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -270,6 +270,8 @@ L<FS::sales> - Sales person class
 
 L<FS::agent> - Agent (reseller) class
 
+L<FS::agent_pkg_class> - Agent (reseller) package class commission class
+
 L<FS::agent_type> - Agent type class
 
 L<FS::type_pkgs> - Class linking agent types (see L<FS::agent_type>) with package definitions (see L<FS::part_pkg>)
@@ -282,6 +284,10 @@ L<FS::agent_payment_gateway> - Agent payment gateway class
 
 L<FS::cust_svc> - Service class
 
+L<FS::part_export_machine> - Export hostname choice class
+
+L<FS::svc_export_machine> - Customer export hostname class
+
 L<FS::cust_pkg> - Customer package class
 
 L<FS::cust_pkg_option> - Customer package option class
index 4de2948..b38c267 100644 (file)
@@ -117,6 +117,7 @@ tie my %rights, 'Tie::IxHash',
     'Cancel customer',
     'Complimentary customer', #aka users-allow_comp 
     'Merge customer',
+    'Merge customer across agents',
     { rightname=>'Delete customer', desc=>"Enable customer deletions. Be very careful! Deleting a customer will remove all traces that this customer ever existed! It should probably only be used when auditing a legacy database. Normally, you cancel all of a customer's packages if they cancel service." }, #aka. deletecustomers
     'Bill customer now', #NEW
     'Bulk send customer notices', #NEW
@@ -177,7 +178,9 @@ tie my %rights, 'Tie::IxHash',
   'Customer invoice / financial info rights' => [
     'View invoices',
     'Resend invoices', #NEWNEW
-    'Delete invoices', #new, but no need to phase in
+    'Void invoices',
+    'Unvoid invoices',
+    'Delete invoices',
     'View customer tax exemptions', #yow
     'Add customer tax adjustment', #new, but no need to phase in
     'View customer batched payments', #NEW
@@ -227,11 +230,11 @@ tie my %rights, 'Tie::IxHash',
   ###
   # customer voiding rights..
   ###
-  'Customer void rights' => [
+  'Customer payment void rights' => [
     { rightname=>'Credit card void', desc=>'Enable local-only voiding of echeck payments in addition to refunds against the payment gateway.' }, #aka. cc-void 
     { rightname=>'Echeck void', desc=>'Enable local-only voiding of echeck payments in addition to refunds against the payment gateway.' }, #aka. echeck-void
-    'Regular void',
-    { rightname=>'Unvoid', desc=>'Enable unvoiding of voided payments' }, #aka. unvoid 
+    'Void payments',
+    { rightname=>'Unvoid payments', desc=>'Enable unvoiding of voided payments' }, #aka. unvoid 
     
   
   ],
@@ -261,6 +264,7 @@ tie my %rights, 'Tie::IxHash',
     'List all customers',
     'Advanced customer search',
     'List zip codes', #NEW
+    'List quotations',
     'List invoices',
     'List packages',
     'Summarize packages',
@@ -396,6 +400,7 @@ sub default_superuser_rights {
     'Delete refund', #?
     'Edit customer package dates',
     'Time queue',
+    'Usage: Time worked',
     'Redownload resolved batches',
     'Raw SQL',
     'Configuration download',
@@ -404,6 +409,7 @@ sub default_superuser_rights {
     'Edit usage',
     'Credit card void',
     'Echeck void',
+    'Edit customer package dates',
   );
 
   no warnings 'uninitialized';
index 534b48a..c4094ff 100644 (file)
@@ -26,6 +26,7 @@ my %allowed_comps = map { $_=>1 } qw(
 
 my %session_comps = map { $_=>1 } qw(
   /elements/location.html
+  /elements/tr-amount_fee.html
   /edit/cust_main/first_pkg/select-part_pkg.html
 );
 
@@ -41,6 +42,29 @@ my %session_callbacks = (
     return ''; #no error
   },
 
+  '/elements/tr-amount_fee.html' => sub {
+    my( $custnum, $argsref ) = @_;
+
+    my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+      or return "unknown custnum $custnum";
+
+    my $conf = new FS::Conf;
+
+    my %args = @$argsref;
+    %args = (
+      %args,
+      'process-pkgpart'    =>
+        scalar($conf->config('selfservice_process-pkgpart', $cust_main->agentnum)),
+      'process-display'    => scalar($conf->config('selfservice_process-display')),
+      'process-skip_first' => $conf->exists('selfservice_process-skip_first'),
+      'num_payments'       => scalar($cust_main->cust_pay), 
+      'surcharge_percentage' => scalar($conf->config('credit-card-surcharge-percentage')),
+    );
+    @$argsref = ( %args );
+
+    return ''; #no error
+  },
+
   '/edit/cust_main/first_pkg/select-part_pkg.html' => sub {
     my( $custnum, $argsref ) = @_;
     my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
index 54799b8..3f7c004 100644 (file)
@@ -14,6 +14,7 @@ use Business::CreditCard;
 use HTML::Entities;
 use Text::CSV_XS;
 use Spreadsheet::WriteExcel;
+use OLE::Storage_Lite;
 use FS::UI::Web::small_custview qw(small_custview); #less doh
 use FS::UI::Web;
 use FS::UI::bytecount qw( display_bytecount );
@@ -38,6 +39,7 @@ use FS::cust_main;
 use FS::cust_bill;
 use FS::legacy_cust_bill;
 use FS::cust_main_county;
+use FS::part_pkg;
 use FS::cust_pkg;
 use FS::payby;
 use FS::acct_rt_transaction;
@@ -195,8 +197,6 @@ sub login {
 
   } else {
 
-warn Dumper($p);
-
     my $svc_domain = qsearchs('svc_domain', { 'domain' => $p->{'domain'} } )
       or return { error => 'Domain '. $p->{'domain'}. ' not found' };
 
@@ -926,6 +926,21 @@ sub validate_payment {
   my $amount = $1;
   return { error => 'Amount must be greater than 0' } unless $amount > 0;
 
+  #false laziness w/tr-amount_fee.html, but we don't want selfservice users
+  #changing the hidden form values
+  my $conf = new FS::Conf;
+  my $fee_display = $conf->config('selfservice_process-display') || 'add';
+  my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
+  my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
+  if ( $fee_display eq 'add'
+         and $fee_pkgpart
+         and ! $fee_skip_first || scalar($cust_main->cust_pay)
+     )
+  {
+    my $fee_pkg = qsearchs('part_pkg', { pkgpart=>$fee_pkgpart } );
+    $amount = sprintf('%.2f', $amount + $fee_pkg->option('setup_fee') );
+  }
+
   $p->{'discount_term'} =~ /^\s*(\d*)\s*$/
     or return { 'error' => gettext('illegal_discount_term'). ': '. $p->{'discount_term'} };
   my $discount_term = $1;
@@ -1085,6 +1100,26 @@ sub do_process_payment {
   );
   return { 'error' => $error } if $error;
 
+  #no error, so order the fee package if applicable...
+  my $conf = new FS::Conf;
+  my $fee_pkgpart = $conf->config('selfservice_process-pkgpart', $cust_main->agentnum);
+  my $fee_skip_first = $conf->exists('selfservice_process-skip_first');
+  
+  if ( $fee_pkgpart and ! $fee_skip_first || scalar($cust_main->cust_pay) ) {
+
+    my $cust_pkg = new FS::cust_pkg { 'pkgpart' => $fee_pkgpart };
+
+    $error = $cust_main->order_pkg( 'cust_pkg' => $cust_pkg );
+    return { 'error' => "payment processed successfully, but error ordering fee: $error" }
+      if $error;
+
+    #and generate an invoice for it now too
+    $error = $cust_main->bill( 'pkg_list' => [ $cust_pkg ] );
+    return { 'error' => "payment processed and fee ordered sucessfully, but error billing fee: $error" }
+      if $error;
+
+  }
+
   $cust_main->apply_payments;
 
   if ( $validate->{'save'} ) {
index 13a84bc..5c43b3a 100644 (file)
@@ -839,6 +839,13 @@ sub reason_type_options {
   },
 
   {
+    'key'         => 'cust_main-select-prorate_day',
+    'section'     => 'billing',
+    'description' => 'When used with prorate or anniversary packages, allows the selection of the prorate day of month, on a per-customer basis',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'encryption',
     'section'     => 'billing',
     'description' => 'Enable encryption of credit cards and echeck numbers',
@@ -1356,7 +1363,7 @@ and customer address. Include units.',
   {
     'key'         => 'invoice_latexextracouponspace',
     'section'     => 'invoicing',
-    'description' => 'Optional LaTeX invoice textheight space to reserve for a tear off coupon. Include units.',
+    'description' => 'Optional LaTeX invoice textheight space to reserve for a tear off coupon.  Include units.  Default is 3.6cm',
     'type'        => 'text',
     'per_agent'   => 1,
     'validate'    => sub { shift =~
@@ -1978,6 +1985,14 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'national_id-country',
+    'section'     => 'UI',
+    'description' => 'Track a national identification number, for specific countries.',
+    'type'        => 'select',
+    'select_enum' => [ '', 'MY' ],
+  },
+
+  {
     'key'         => 'show_bankstate',
     'section'     => 'UI',
     'description' => "Turns on display/collection of state for bank accounts in the web interface.  Sometimes required by electronic check (ACH) processors.",
@@ -2540,6 +2555,7 @@ and customer address. Include units.',
     'section'     => 'billing',
     'description' => 'Package to add to each manual credit card and ACH payment entered by employees from the backend.  Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.',
     'type'        => 'select-part_pkg',
+    'per_agent'   => 1,
   },
 
   {
@@ -2565,6 +2581,7 @@ and customer address. Include units.',
     'section'     => 'billing',
     'description' => 'Package to add to each manual credit card and ACH payment entered by the customer themselves in the self-service interface.  Enabling this option may be in violation of your merchant agreement(s), so please check it(/them) carefully before enabling this option.',
     'type'        => 'select-part_pkg',
+    'per_agent'   => 1,
   },
 
   {
@@ -2585,30 +2602,30 @@ and customer address. Include units.',
     'type'        => 'checkbox',
   },
 
-  {
-    'key'         => 'suto_process-pkgpart',
-    'section'     => 'billing',
-    'description' => 'Package to add to each automatic credit card and ACH payment processed by billing events.  Enabling this option may be in violation of your merchant agreement(s), so please check them carefully before enabling this option.',
-    'type'        => 'select-part_pkg',
-  },
-
 #  {
-#    'key'         => 'auto_process-display',
+#    'key'         => 'auto_process-pkgpart',
 #    'section'     => 'billing',
-#    'description' => 'When using auto_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.',
-#    'type'        => 'select',
-#    'select_hash' => [
-#                       'add'      => 'Add fee to amount entered',
-#                       'subtract' => 'Subtract fee from amount entered',
-#                     ],
+#    'description' => 'Package to add to each automatic credit card and ACH payment processed by billing events.  Enabling this option may be in violation of your merchant agreement(s), so please check them carefully before enabling this option.',
+#    'type'        => 'select-part_pkg',
+#  },
+#
+##  {
+##    'key'         => 'auto_process-display',
+##    'section'     => 'billing',
+##    'description' => 'When using auto_process-pkgpart, add the fee to the amount entered (default), or subtract the fee from the amount entered.',
+##    'type'        => 'select',
+##    'select_hash' => [
+##                       'add'      => 'Add fee to amount entered',
+##                       'subtract' => 'Subtract fee from amount entered',
+##                     ],
+##  },
+#
+#  {
+#    'key'         => 'auto_process-skip_first',
+#    'section'     => 'billing',
+#    'description' => "When using auto_process-pkgpart, omit the fee if it is the customer's first payment.",
+#    'type'        => 'checkbox',
 #  },
-
-  {
-    'key'         => 'auto_process-skip_first',
-    'section'     => 'billing',
-    'description' => "When using auto_process-pkgpart, omit the fee if it is the customer's first payment.",
-    'type'        => 'checkbox',
-  },
 
   {
     'key'         => 'allow_negative_charges',
@@ -3286,7 +3303,7 @@ and customer address. Include units.',
   {
     'key'         => 'cust_pkg-show_fcc_voice_grade_equivalent',
     'section'     => 'UI',
-    'description' => "Show a field on package definitions for assigning a DS0 equivalency number suitable for use on FCC form 477.",
+    'description' => "Show fields on package definitions for FCC Form 477 classification",
     'type'        => 'checkbox',
   },
 
@@ -3423,7 +3440,7 @@ and customer address. Include units.',
   {
     'key'         => 'invoice-unitprice',
     'section'     => 'invoicing',
-    'description' => 'Enable unit pricing on invoices.',
+    'description' => 'Enable unit pricing on invoices and quantities on packages.',
     'type'        => 'checkbox',
   },
 
@@ -3452,7 +3469,7 @@ and customer address. Include units.',
   {
     'key'         => 'postal_invoice-recurring_only',
     'section'     => 'billing',
-    'description' => 'The postal invoice fee is omitted on invoices without reucrring charges when this is set.',
+    'description' => 'The postal invoice fee is omitted on invoices without recurring charges when this is set.',
     'type'        => 'checkbox',
   },
 
@@ -3710,6 +3727,13 @@ and customer address. Include units.',
   },
 
   {
+    'key'         => 'cust_main-enable_anniversary_date',
+    'section'     => 'UI',
+    'description' => 'Enable tracking of an anniversary date with each customer record',
+    'type'        => 'checkbox',
+  },
+
+  {
     'key'         => 'cust_main-edit_calling_list_exempt',
     'section'     => 'UI',
     'description' => 'Display the "calling_list_exempt" checkbox on customer edit.',
@@ -5223,6 +5247,13 @@ and customer address. Include units.',
     ],
   },
 
+  {
+    'key'         => 'agent-email_day',
+    'section'     => '',
+    'description' => 'On this day of each month, agents with master customer records containing email addresses will be emailed a list of their customers and balances.',
+    'type'        => 'text',
+  },
+
   { key => "apacheroot", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachine", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
   { key => "apachemachines", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
diff --git a/FS/FS/Cron/agent_email.pm b/FS/FS/Cron/agent_email.pm
new file mode 100644 (file)
index 0000000..992aa35
--- /dev/null
@@ -0,0 +1,79 @@
+package FS::Cron::agent_email;
+use base qw( Exporter );
+
+use strict;
+use vars qw( @EXPORT_OK $DEBUG );
+use Date::Simple qw(today);
+use URI::Escape;
+use FS::Mason qw( mason_interps );
+use FS::Conf;
+use FS::Misc qw(send_email);
+use FS::Record qw(qsearch);# qsearchs);
+use FS::agent;
+
+@EXPORT_OK = qw ( agent_email );
+$DEBUG = 0;
+
+sub agent_email {
+  my %opt = @_;
+
+  my $conf = new FS::Conf;
+
+  my $day = $conf->config('agent-email_day') or return;
+  return unless $day == today->day;
+
+  if ( 1 ) { #XXX if ( %%%RT_ENABLED%%% ) {
+    require RT;
+    RT::LoadConfig();
+    RT::Init();
+    RT::ConnectToDatabase();
+  }
+
+  my $from = $conf->config('invoice_from');
+
+  my $outbuf = '';;
+  my( $fs_interp, $rt_interp ) = mason_interps('standalone', 'outbuf'=>\$outbuf);
+
+  my $comp = '/search/cust_main.html';
+  my %args = (
+    'cust_fields' => 'Cust# | Cust. Status | Customer | Current Balance',
+    '_type'       => 'html-print',
+  );
+  my $query = join('&', map "$_=".uri_escape($args{$_}), keys %args );
+
+  my $extra_sql = $opt{a} ? " AND agentnum IN ( $opt{a} ) " : '';
+
+  foreach my $agent ( qsearch({
+                        'table'     => 'agent',
+                        'hashref'   => {
+                          'disabled'      => '',
+                          'agent_custnum' => { op=>'!=', value=>'' },
+                        },
+                        'extra_sql' => $extra_sql,
+                      })
+                    )
+  {
+
+    $FS::Mason::Request::QUERY_STRING = $query. '&agentnum='. $agent->agentnum;
+    $fs_interp->exec($comp);
+
+    my @email = $agent->agent_cust_main->invoicing_list or next;
+
+    warn "emailing ". join(',',@email). " for agent ". $agent->agent. "\n"
+      if $DEBUG;
+    send_email(
+      'from'         => $from,
+      'to'           => \@email,
+      'subject'      => 'Customer report',
+      'body'         => $outbuf,
+      'content-type' => 'text/html',
+      #'content-encoding'
+    ); 
+
+    $outbuf = '';
+
+  }
+
+}
+
+1;
index c7cedaf..0ab37dd 100644 (file)
@@ -103,7 +103,7 @@ sub batch_receive {
     if ( $gateway->batch_processor->can('default_transport') ) {
       warn "Importing results from '".$gateway->label."'\n" if $DEBUG;
       $error = eval { 
-        FS::pay_batch->import_from_gateway( $gateway, debug => $DEBUG ) 
+        FS::pay_batch->import_from_gateway( gateway =>$gateway, debug => $DEBUG ) 
       } || $@;
       if ( $error ) {
         # this we can roll back
index 51edd97..11af25e 100644 (file)
@@ -91,8 +91,9 @@ if ( -e $addl_handler_use_file ) {
   use Text::CSV_XS;
   use Spreadsheet::WriteExcel;
   use Spreadsheet::WriteExcel::Utility;
+  use OLE::Storage_Lite;
   use Excel::Writer::XLSX;
-  use Excel::Writer::XLSX::Utility;
+  #use Excel::Writer::XLSX::Utility; #redundant with above
 
   use Business::CreditCard 0.30; #for mask-aware cardtype()
   use NetAddr::IP;
@@ -315,6 +316,16 @@ if ( -e $addl_handler_use_file ) {
   use FS::quotation;
   use FS::quotation_pkg;
   use FS::quotation_pkg_discount;
+  use FS::cust_bill_void;
+  use FS::cust_bill_pkg_void;
+  use FS::cust_bill_pkg_detail_void;
+  use FS::cust_bill_pkg_display_void;
+  use FS::cust_bill_pkg_tax_location_void;
+  use FS::cust_bill_pkg_tax_rate_location_void;
+  use FS::cust_tax_exempt_pkg_void;
+  use FS::cust_bill_pkg_discount_void;
+  use FS::agent_pkg_class;
+  use FS::svc_export_machine;
   # Sammath Naur
 
   if ( $FS::Mason::addl_handler_use ) {
@@ -358,7 +369,7 @@ if ( -e $addl_handler_use_file ) {
 
       use RT::Interface::Web::Request;
 
-      #nother undeclared web UI dep (for ticket links graph)
+      #another undeclared web UI dep (for ticket links graph)
       use IPC::Run::SafeHandles;
 
       #slow, unreliable, segfaults and is optional
index 0d21df4..36c46dc 100644 (file)
@@ -4,6 +4,7 @@ use strict;
 use warnings;
 use vars qw( $FSURL $QUERY_STRING );
 use base 'HTML::Mason::Request';
+use FS::Trace;
 
 $FSURL = 'http://Set/FS_Mason_Request_FSURL/in_standalone_mode/';
 $QUERY_STRING = '';
@@ -11,21 +12,27 @@ $QUERY_STRING = '';
 sub new {
     my $class = shift;
 
+    FS::Trace->log('creating new FS::Mason::Request object');
+
     my $superclass = $HTML::Mason::ApacheHandler::VERSION ?
                      'HTML::Mason::Request::ApacheHandler' :
                      $HTML::Mason::CGIHandler::VERSION ?
                      'HTML::Mason::Request::CGI' :
                      'HTML::Mason::Request';
 
+    FS::Trace->log('  altering superclass');
     $class->alter_superclass( $superclass );
 
+    FS::Trace->log('  setting valid params');
     #huh... shouldn't alter_superclass take care of this for us?
     __PACKAGE__->valid_params( %{ $superclass->valid_params() } );
 
+    FS::Trace->log('  freeside_setup');
     my %opt = @_;
     my $mode = $superclass =~ /Apache/i ? 'apache' : 'standalone';
     $class->freeside_setup($opt{'comp'}, $mode);
 
+    FS::Trace->log('  SUPER::new');
     $class->SUPER::new(@_);
 
 }
@@ -38,6 +45,8 @@ my $protect_fds;
 sub freeside_setup {
     my( $class, $filename, $mode ) = @_;
 
+    FS::Trace->log('    protecting fds');
+
     #from rt/bin/webmux.pl(.in)
     if ( !$protect_fds && $ENV{'MOD_PERL'} && exists $ENV{'MOD_PERL_API_VERSION'}
         && $ENV{'MOD_PERL_API_VERSION'} >= 2
@@ -57,6 +66,8 @@ sub freeside_setup {
 
     if ( $filename =~ qr(/REST/\d+\.\d+/NoAuth/) ) {
 
+      FS::Trace->log('    handling RT REST/NoAuth file');
+
       package HTML::Mason::Commands; #?
       use FS::UID qw( adminsuidsetup );
 
@@ -65,10 +76,13 @@ sub freeside_setup {
       ##old installs w/fs_selfs or selfserv??
       #&adminsuidsetup('fs_selfservice');
 
+      FS::Trace->log('    adminsuidsetup fs_queue');
       &adminsuidsetup('fs_queue');
 
     } else {
 
+      FS::Trace->log('    handling regular file');
+
       package HTML::Mason::Commands;
       use vars qw( $cgi $p $fsurl ); # $lh ); #not using /mt
       use Encode;
@@ -77,6 +91,7 @@ sub freeside_setup {
 
       if ( $mode eq 'apache' ) {
         $cgi = new CGI;
+        FS::Trace->log('    cgisuidsetup');
         &cgisuidsetup($cgi);
         #&cgisuidsetup($r);
         $fsurl = rooturl();
@@ -91,6 +106,7 @@ sub freeside_setup {
         die "unknown mode $mode";
       }
 
+    FS::Trace->log('    UTF-8-decoding form data');
     #
     foreach my $param ( $cgi->param ) {
       my @values = $cgi->param($param);
@@ -102,6 +118,8 @@ sub freeside_setup {
     
   }
 
+  FS::Trace->log('    done');
+
 }
 
 sub callback {
index 2be9ec2..297e39f 100644 (file)
@@ -913,16 +913,6 @@ sub ocr_image {
   @lines;
 }
 
-=item spool_formats
-  
-Returns a list of the invoice spool formats.
-
-=cut
-
-sub spool_formats {
-  qw(default oneline billco bridgestone)
-}
-
 =back
 
 =head1 BUGS
index 0ac269f..ca68c35 100644 (file)
@@ -2421,10 +2421,9 @@ sub ut_coordn {
 
 }
 
-
 =item ut_domain COLUMN
 
-Check/untaint host and domain names.
+Check/untaint host and domain names.  May not be null.
 
 =cut
 
@@ -2432,11 +2431,27 @@ sub ut_domain {
   my( $self, $field ) = @_;
   #$self->getfield($field) =~/^(\w+\.)*\w+$/
   $self->getfield($field) =~/^(([\w\-]+\.)*\w+)$/
-    or return "Illegal (domain) $field: ". $self->getfield($field);
+    or return "Illegal (hostname) $field: ". $self->getfield($field);
   $self->setfield($field,$1);
   '';
 }
 
+=item ut_domainn COLUMN
+
+Check/untaint host and domain names.  May be null.
+
+=cut
+
+sub ut_domainn {
+  my( $self, $field ) = @_;
+  if ( $self->getfield($field) =~ /^()$/ ) {
+    $self->setfield($field,'');
+    '';
+  } else {
+    $self->ut_domain($field);
+  }
+}
+
 =item ut_name COLUMN
 
 Check/untaint proper names; allows alphanumerics, spaces and the following
index 4c94fff..49bb8a8 100644 (file)
@@ -45,8 +45,8 @@ Documentation.
 );
 
 @technology = (
-  'Asymetric xDSL',
-  'Symetric xDSL',
+  'Asymmetric xDSL',
+  'Symmetric xDSL',
   'Other Wireline',
   'Cable Modem',
   'Optical Carrier',
index 61bd00c..6ad4b74 100644 (file)
@@ -445,7 +445,7 @@ sub tables_hashref {
   my @taxrate_type  = ( 'decimal',   '',     '14,8' ); # requires pg 8 for 
   my @taxrate_typen = ( 'decimal',   'NULL', '14,8' ); # fs-upgrade to work
 
-  my $username_len = 32; #usernamemax config file
+  my $username_len = 64; #usernamemax config file
 
     # name type nullability length default local
 
@@ -473,16 +473,16 @@ sub tables_hashref {
       'index' => [ ['typenum'], ['disabled'], ['agent_custnum'] ],
     },
 
-    'sales' => {
+    'agent_pkg_class' => {
       'columns' => [
-        'salesnum',          'serial',    '',       '', '', '', 
-        'salesperson',      'varchar',    '',  $char_d, '', '', 
-        'agentnum',             'int', 'NULL',      '', '', '', 
-        'disabled',            'char', 'NULL',       1, '', '', 
+        'agentpkgclassnum',    'serial',     '',    '', '', '',
+        'agentnum',               'int',     '',    '', '', '',
+        'classnum',               'int', 'NULL',    '', '', '',
+        'commission_percent', 'decimal',     '', '7,4', '', '',
       ],
-      'primary_key' => 'salesnum',
-      'unique' => [],
-      'index' => [ ['salesnum'], ['disabled'] ],
+      'primary_key' => 'agentpkgclassnum',
+      'unique'      => [ [ 'agentnum', 'classnum' ], ],
+      'index'       => [],
     },
 
     'agent_type' => {
@@ -506,6 +506,18 @@ sub tables_hashref {
       'index' => [ ['typenum'] ],
     },
 
+    'sales' => {
+      'columns' => [
+        'salesnum',          'serial',    '',       '', '', '', 
+        'salesperson',      'varchar',    '',  $char_d, '', '', 
+        'agentnum',             'int', 'NULL',      '', '', '', 
+        'disabled',            'char', 'NULL',       1, '', '', 
+      ],
+      'primary_key' => 'salesnum',
+      'unique' => [],
+      'index' => [ ['salesnum'], ['disabled'] ],
+    },
+
     'cust_attachment' => {
       'columns' => [
         'attachnum', 'serial', '', '', '', '',
@@ -551,6 +563,35 @@ sub tables_hashref {
       'index' => [ ['custnum'], ['_date'], ['statementnum'], ['agent_invid'] ],
     },
 
+    'cust_bill_void' => {
+      'columns' => [
+        #regular fields
+        'invnum',       'int',     '', '', '', '', 
+        'custnum',      'int',     '', '', '', '', 
+        '_date',        @date_type,        '', '', 
+        'charged',      @money_type,       '', '', 
+        'invoice_terms', 'varchar', 'NULL', $char_d, '', '',
+
+        #customer balance info at invoice generation time
+        'previous_balance',   @money_typen, '', '',  #eventually not nullable
+        'billing_balance',    @money_typen, '', '',  #eventually not nullable
+
+        #specific use cases
+        'closed',      'char', 'NULL',  1, '', '', #not yet used much
+        'statementnum', 'int', 'NULL', '', '', '', #invoice aggregate statements
+        'agent_invid',  'int', 'NULL', '', '', '', #(varchar?) importing legacy
+        'promised_date', @date_type,       '', '',
+
+        #void fields
+        'void_date', @date_type, '', '', 
+        'reason',    'varchar',   'NULL', $char_d, '', '', 
+        'void_usernum',   'int', 'NULL', '', '', '',
+      ],
+      'primary_key' => 'invnum',
+      'unique' => [ [ 'custnum', 'agent_invid' ] ], #agentnum?  huh
+      'index' => [ ['custnum'], ['_date'], ['statementnum'], ['agent_invid'], [ 'void_usernum' ] ],
+    },
+
     #for importing invoices from a legacy system for display purposes only
     # no effect upon balance
     'legacy_cust_bill' => {
@@ -787,6 +828,101 @@ sub tables_hashref {
       'index'  => [ [ 'billpkgnum' ], [ 'taxnum' ], [ 'taxratelocationnum' ] ],
     },
 
+    'cust_bill_pkg_void' => {
+      'columns' => [
+        'billpkgnum',           'int',     '',      '', '', '', 
+        'invnum',               'int',     '',      '', '', '', 
+        'pkgnum',               'int',     '',      '', '', '', 
+        'pkgpart_override',     'int', 'NULL',      '', '', '', 
+        'setup',               @money_type,             '', '', 
+        'recur',               @money_type,             '', '', 
+        'sdate',               @date_type,              '', '', 
+        'edate',               @date_type,              '', '', 
+        'itemdesc',         'varchar', 'NULL', $char_d, '', '', 
+        'itemcomment',      'varchar', 'NULL', $char_d, '', '', 
+        'section',          'varchar', 'NULL', $char_d, '', '', 
+        'freq',             'varchar', 'NULL', $char_d, '', '',
+        'quantity',             'int', 'NULL',      '', '', '',
+        'unitsetup',           @money_typen,            '', '', 
+        'unitrecur',           @money_typen,            '', '', 
+        'hidden',              'char', 'NULL',       1, '', '',
+        #void fields
+        'void_date', @date_type, '', '', 
+        'reason',    'varchar',   'NULL', $char_d, '', '', 
+        'void_usernum',   'int', 'NULL', '', '', '',
+      ],
+      'primary_key' => 'billpkgnum',
+      'unique' => [],
+      'index' => [ ['invnum'], [ 'pkgnum' ], [ 'itemdesc' ], [ 'void_usernum' ], ],
+    },
+
+    'cust_bill_pkg_detail_void' => {
+      'columns' => [
+        'detailnum',  'int', '', '', '', '', 
+        'billpkgnum', 'int', 'NULL', '', '', '',        # should not be nullable
+        'pkgnum',  'int', 'NULL', '', '', '',           # deprecated
+        'invnum',  'int', 'NULL', '', '', '',           # deprecated
+        'amount',  'decimal', 'NULL', '10,4', '', '',
+        'format',  'char', 'NULL', 1, '', '',
+        'classnum', 'int', 'NULL', '', '', '',
+        'duration', 'int', 'NULL', '',  0, '',
+        'phonenum', 'varchar', 'NULL', 15, '', '',
+        'accountcode', 'varchar',  'NULL',      20, '', '',
+        'startdate',  @date_type, '', '', 
+        'regionname', 'varchar', 'NULL', $char_d, '', '',
+        'detail',  'varchar', '', 255, '', '', 
+      ],
+      'primary_key' => 'detailnum',
+      'unique' => [],
+      'index' => [ [ 'billpkgnum' ], [ 'classnum' ], [ 'pkgnum', 'invnum' ] ],
+    },
+
+    'cust_bill_pkg_display_void' => {
+      'columns' => [
+        'billpkgdisplaynum',    'int', '', '', '', '', 
+        'billpkgnum', 'int', '', '', '', '', 
+        'section',  'varchar', 'NULL', $char_d, '', '', 
+        #'unitsetup', @money_typen, '', '',     #override the linked real one?
+        #'unitrecur', @money_typen, '', '',     #this too?
+        'post_total', 'char', 'NULL', 1, '', '',
+        'type',       'char', 'NULL', 1, '', '',
+        'summary',    'char', 'NULL', 1, '', '',
+      ],
+      'primary_key' => 'billpkgdisplaynum',
+      'unique' => [],
+      'index' => [ ['billpkgnum'], ],
+    },
+
+    'cust_bill_pkg_tax_location_void' => {
+      'columns' => [
+        'billpkgtaxlocationnum',    'int',      '', '', '', '',
+        'billpkgnum',               'int',      '', '', '', '',
+        'taxnum',                   'int',      '', '', '', '',
+        'taxtype',              'varchar',      '', $char_d, '', '',
+        'pkgnum',                   'int',      '', '', '', '',
+        'locationnum',              'int',      '', '', '', '', #redundant?
+        'amount',                   @money_type,        '', '',
+      ],
+      'primary_key' => 'billpkgtaxlocationnum',
+      'unique' => [],
+      'index'  => [ [ 'billpkgnum' ], [ 'taxnum' ], [ 'pkgnum' ], [ 'locationnum' ] ],
+    },
+
+    'cust_bill_pkg_tax_rate_location_void' => {
+      'columns' => [
+        'billpkgtaxratelocationnum',    'int',      '', '', '', '',
+        'billpkgnum',                   'int',      '', '', '', '',
+        'taxnum',                       'int',      '', '', '', '',
+        'taxtype',                  'varchar',      '', $char_d, '', '',
+        'locationtaxid',            'varchar',  'NULL', $char_d, '', '',
+        'taxratelocationnum',           'int',      '', '', '', '',
+        'amount',                       @money_type,        '', '',
+      ],
+      'primary_key' => 'billpkgtaxratelocationnum',
+      'unique' => [],
+      'index'  => [ [ 'billpkgnum' ], [ 'taxnum' ], [ 'taxratelocationnum' ] ],
+    },
+
     'cust_credit' => {
       'columns' => [
         'crednum',  'serial', '', '', '', '', 
@@ -801,6 +937,7 @@ sub tables_hashref {
         'closed',    'char', 'NULL', 1, '', '', 
         'pkgnum', 'int', 'NULL', '', '', '', #desired pkgnum for pkg-balances
         'eventnum', 'int', 'NULL', '', '', '', #triggering event for commission
+        #'commission_agentnum', 'int', 'NULL', '', '', '', #
       ],
       'primary_key' => 'crednum',
       'unique' => [],
@@ -856,8 +993,10 @@ sub tables_hashref {
         'ss',       'varchar', 'NULL', 11, '', '', 
         'stateid', 'varchar', 'NULL', $char_d, '', '', 
         'stateid_state', 'varchar', 'NULL', $char_d, '', '', 
+        'national_id', 'varchar', 'NULL', $char_d, '', '',
         'birthdate' ,@date_type, '', '', 
         'spouse_birthdate' ,@date_type, '', '', 
+        'anniversary_date' ,@date_type, '', '', 
         'signupdate',@date_type, '', '', 
         'dundate',   @date_type, '', '', 
         'company',  'varchar', 'NULL', $char_d, '', '', 
@@ -925,6 +1064,7 @@ sub tables_hashref {
         'email_csv_cdr', 'char', 'NULL', 1, '', '',
         'accountcode_cdr', 'char', 'NULL', 1, '', '',
         'billday',   'int', 'NULL', '', '', '',
+        'prorate_day',   'int', 'NULL', '', '', '',
         'edit_subject', 'char', 'NULL', 1, '', '',
         'locale', 'varchar', 'NULL', 16, '', '', 
         'calling_list_exempt', 'char', 'NULL', 1, '', '',
@@ -993,7 +1133,7 @@ sub tables_hashref {
 #        'middle',    'varchar', 'NULL', $char_d, '', '', 
         'first',     'varchar',     '', $char_d, '', '', 
         'title',     'varchar', 'NULL', $char_d, '', '', #eg Head Bottle Washer
-        'comment',   'varchar', 'NULL', $char_d, '', '', 
+        'comment',   'varchar', 'NULL',     255, '', '', 
         'disabled',     'char', 'NULL',       1, '', '', 
       ],
       'primary_key' => 'contactnum',
@@ -1418,20 +1558,29 @@ sub tables_hashref {
       'columns' => [
         'paynum',    'int',    '',   '', '', '', 
         'custnum',   'int',    '',   '', '', '', 
-        'paid',      @money_type, '', '', 
         '_date',     @date_type, '', '', 
+        'paid',      @money_type, '', '', 
+        'otaker',   'varchar', 'NULL', 32, '', '', 
+        'usernum',   'int', 'NULL', '', '', '',
         'payby',     'char',   '',     4, '', '', # CARD/BILL/COMP, should be
                                                   # index into payby table
                                                   # eventually
         'payinfo',   'varchar',   'NULL', 512, '', '', #see cust_main above
        'paymask', 'varchar', 'NULL', $char_d, '', '', 
+        #'paydate' ?
         'paybatch',  'varchar',   'NULL', $char_d, '', '', #for auditing purposes.
         'closed',    'char', 'NULL', 1, '', '', 
         'pkgnum', 'int', 'NULL', '', '', '', #desired pkgnum for pkg-balances
+        # cash/check deposit info fields
+        'bank',       'varchar', 'NULL', $char_d, '', '',
+        'depositor',  'varchar', 'NULL', $char_d, '', '',
+        'account',    'varchar', 'NULL', 20,      '', '',
+        'teller',     'varchar', 'NULL', 20,      '', '',
+        'batchnum',       'int', 'NULL', '', '', '', #pay_batch foreign key
+
+        #void fields
         'void_date', @date_type, '', '', 
         'reason',    'varchar',   'NULL', $char_d, '', '', 
-        'otaker',   'varchar', 'NULL', 32, '', '', 
-        'usernum',   'int', 'NULL', '', '', '',
         'void_usernum',   'int', 'NULL', '', '', '',
       ],
       'primary_key' => 'paynum',
@@ -1650,6 +1799,19 @@ sub tables_hashref {
       'index' => [ [ 'billpkgnum' ], [ 'pkgdiscountnum' ] ],
     },
 
+    'cust_bill_pkg_discount_void' => {
+      'columns' => [
+        'billpkgdiscountnum',    'int',        '', '', '', '',
+        'billpkgnum',            'int',        '', '', '', '', 
+        'pkgdiscountnum',        'int',        '', '', '', '', 
+        'amount',          @money_type,                '', '', 
+        'months',            'decimal', 'NULL', '7,4', '', '',
+      ],
+      'primary_key' => 'billpkgdiscountnum',
+      'unique' => [],
+      'index' => [ [ 'billpkgnum' ], [ 'pkgdiscountnum' ] ],
+    },
+
     'discount' => {
       'columns' => [
         'discountnum', 'serial',     '',      '', '', '',
@@ -1728,6 +1890,30 @@ sub tables_hashref {
       'index'       => [ [ 'svcnum' ], [ 'optionname' ] ],
     },
 
+    'svc_export_machine' => {
+      'columns' => [
+        'svcexportmachinenum', 'serial', '', '', '', '',
+        'svcnum',                 'int', '', '', '', '', 
+        'exportnum',              'int', '', '', '', '', 
+        'machinenum',             'int', '', '', '', '',
+      ],
+      'primary_key' => 'svcexportmachinenum',
+      'unique'      => [ ['svcnum', 'exportnum'] ],
+      'index'       => [],
+    },
+
+    'part_export_machine' => {
+      'columns' => [
+        'machinenum', 'serial', '', '', '', '',
+        'exportnum',     'int', '', '', '', '',
+        'machine',    'varchar', 'NULL', $char_d, '', '', 
+        'disabled',      'char', 'NULL',       1, '', '',
+      ],
+      'primary_key' => 'machinenum',
+      'unique'      => [ [ 'exportnum', 'machine' ] ],
+      'index'       => [ [ 'exportnum' ] ],
+    },
+
    'part_pkg' => {
       'columns' => [
         'pkgpart',       'serial',    '',   '', '', '', 
@@ -1750,6 +1936,7 @@ sub tables_hashref {
         'credit_weight', 'real',    'NULL', '', '', '',
         'agentnum',      'int',     'NULL', '', '', '', 
         'fcc_ds0s',      'int',     'NULL', '', '', '', 
+        'fcc_voip_class','char',    'NULL',  1, '', '',
         'no_auto',          'char', 'NULL',  1, '', '', 
         'recur_show_zero',  'char', 'NULL',  1, '', '',
         'setup_show_zero',  'char', 'NULL',  1, '', '',
@@ -2460,11 +2647,11 @@ sub tables_hashref {
 
     'part_export' => {
       'columns' => [
-        'exportnum', 'serial', '', '', '', '', 
+        'exportnum',   'serial',     '',      '', '', '', 
         'exportname', 'varchar', 'NULL', $char_d, '', '',
-        'machine', 'varchar', '', $char_d, '', '', 
-        'exporttype', 'varchar', '', $char_d, '', '', 
-        'nodomain',     'char', 'NULL', 1, '', '', 
+        'machine',    'varchar', 'NULL', $char_d, '', '', 
+        'exporttype', 'varchar',     '', $char_d, '', '', 
+        'nodomain',      'char', 'NULL',       1, '', '', 
       ],
       'primary_key' => 'exportnum',
       'unique'      => [],
@@ -2501,6 +2688,8 @@ sub tables_hashref {
         'groupname',    'varchar', '', $char_d, '', '', 
         'description',  'varchar', 'NULL', $char_d, '', '', 
         'priority', 'int', '', '', '1', '',
+        'speed_up', 'int', 'NULL', '', '', '',
+        'speed_down', 'int', 'NULL', '', '', '',
       ],
       'primary_key' => 'groupnum',
       'unique'      => [ ['groupname'] ],
@@ -2509,16 +2698,16 @@ sub tables_hashref {
 
     'radius_attr' => {
       'columns' => [
-        'attrnum',   'serial', '', '', '', '',
-        'groupnum',     'int', '', '', '', '',
+        'attrnum',   'serial', '',      '', '', '',
+        'groupnum',     'int', '',      '', '', '',
         'attrname', 'varchar', '', $char_d, '', '',
-        'value',    'varchar', '', $char_d, '', '',
-        'attrtype',    'char', '', 1, '', '',
-        'op',          'char', '', 2, '', '',
+        'value',    'varchar', '',     255, '', '',
+        'attrtype',    'char', '',       1, '', '',
+        'op',          'char', '',       2, '', '',
       ],
       'primary_key' => 'attrnum',
-      'unique'      => [ ['groupnum','attrname'] ], #?
-      'index'       => [],
+      'unique'      => [],
+      'index'       => [ ['groupnum'], ],
     },
 
     'msgcat' => {
@@ -2553,10 +2742,42 @@ sub tables_hashref {
         #'custnum',      'int', '', '', '', ''
         'billpkgnum',   'int', '', '', '', '', 
         'taxnum',       'int', '', '', '', '', 
-        'year',         'int', '', '', '', '', 
-        'month',        'int', '', '', '', '', 
+        'year',         'int', 'NULL', '', '', '', 
+        'month',        'int', 'NULL', '', '', '', 
+        'creditbillpkgnum', 'int', 'NULL', '', '', '',
+        'amount',       @money_type, '', '', 
+        # exemption type flags
+        'exempt_cust',          'char', 'NULL', 1, '', '',
+        'exempt_setup',         'char', 'NULL', 1, '', '',
+        'exempt_recur',         'char', 'NULL', 1, '', '',
+        'exempt_cust_taxname',  'char', 'NULL', 1, '', '',
+        'exempt_monthly',       'char', 'NULL', 1, '', '',
+      ],
+      'primary_key' => 'exemptpkgnum',
+      'unique' => [],
+      'index'  => [ [ 'taxnum', 'year', 'month' ],
+                    [ 'billpkgnum' ],
+                    [ 'taxnum' ],
+                    [ 'creditbillpkgnum' ],
+                  ],
+    },
+
+    'cust_tax_exempt_pkg_void' => {
+      'columns' => [
+        'exemptpkgnum',  'int', '', '', '', '', 
+        #'custnum',      'int', '', '', '', ''
+        'billpkgnum',   'int', '', '', '', '', 
+        'taxnum',       'int', '', '', '', '', 
+        'year',         'int', 'NULL', '', '', '', 
+        'month',        'int', 'NULL', '', '', '', 
         'creditbillpkgnum', 'int', 'NULL', '', '', '',
         'amount',       @money_type, '', '', 
+        # exemption type flags
+        'exempt_cust',          'char', 'NULL', 1, '', '',
+        'exempt_setup',         'char', 'NULL', 1, '', '',
+        'exempt_recur',         'char', 'NULL', 1, '', '',
+        'exempt_cust_taxname',  'char', 'NULL', 1, '', '',
+        'exempt_monthly',       'char', 'NULL', 1, '', '',
       ],
       'primary_key' => 'exemptpkgnum',
       'unique' => [],
@@ -3502,6 +3723,8 @@ sub tables_hashref {
         'reason_type',   'int',  '', '', '', '', 
         'reason',        'text', '', '', '', '', 
         'disabled',      'char',    'NULL', 1, '', '', 
+        'unsuspend_pkgpart', 'int',  'NULL', '', '', '',
+        'unsuspend_hold','char',    'NULL', 1, '', '',
       ],
       'primary_key' => 'reasonnum',
       'unique' => [],
diff --git a/FS/FS/TemplateItem_Mixin.pm b/FS/FS/TemplateItem_Mixin.pm
new file mode 100644 (file)
index 0000000..6d7ea26
--- /dev/null
@@ -0,0 +1,317 @@
+package FS::TemplateItem_Mixin;
+
+use strict;
+use vars qw( $DEBUG $me ); # but NOT $conf
+use Carp;
+use FS::UID;
+use FS::Record qw( qsearch qsearchs dbh );
+use FS::part_pkg;
+use FS::cust_pkg;
+
+$DEBUG = 0;
+$me = '[FS::TemplateItem_Mixin]';
+
+=item cust_pkg
+
+Returns the package (see L<FS::cust_pkg>) for this invoice line item.
+
+=cut
+
+sub cust_pkg {
+  my $self = shift;
+  carp "$me $self -> cust_pkg" if $DEBUG;
+  qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
+}
+
+=item part_pkg
+
+Returns the package definition for this invoice line item.
+
+=cut
+
+sub part_pkg {
+  my $self = shift;
+  if ( $self->pkgpart_override ) {
+    qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
+  } else {
+    my $part_pkg;
+    my $cust_pkg = $self->cust_pkg;
+    $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
+    $part_pkg;
+  }
+
+}
+
+=item desc
+
+Returns a description for this line item.  For typical line items, this is the
+I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
+For one-shot line items and named taxes, it is the I<itemdesc> field of this
+line item, and for generic taxes, simply returns "Tax".
+
+=cut
+
+sub desc {
+  my $self = shift;
+
+  if ( $self->pkgnum > 0 ) {
+    $self->itemdesc || $self->part_pkg->pkg;
+  } else {
+    my $desc = $self->itemdesc || 'Tax';
+    $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
+    $desc;
+  }
+}
+
+=item details [ OPTION => VALUE ... ]
+
+Returns an array of detail information for the invoice line item.
+
+Currently available options are: I<format>, I<escape_function> and
+I<format_function>.
+
+If I<format> is set to html or latex then the array members are improved
+for tabular appearance in those environments if possible.
+
+If I<escape_function> is set then the array members are processed by this
+function before being returned.
+
+I<format_function> overrides the normal HTML or LaTeX function for returning
+formatted CDRs.  It can be set to a subroutine which returns an empty list
+to skip usage detail:
+
+  'format_function' => sub { () },
+
+=cut
+
+sub details {
+  my ( $self, %opt ) = @_;
+  my $escape_function = $opt{escape_function} || sub { shift };
+
+  my $csv = new Text::CSV_XS;
+
+  if ( $opt{format_function} ) {
+
+    #this still expects to be passed a cust_bill_pkg_detail object as the
+    #second argument, which is expensive
+    carp "deprecated format_function passed to cust_bill_pkg->details";
+    my $format_sub = $opt{format_function} if $opt{format_function};
+
+    map { ( $_->format eq 'C'
+              ? &{$format_sub}( $_->detail, $_ )
+              : &{$escape_function}( $_->detail )
+          )
+        }
+      qsearch ({ 'table'    => $self->detail_table,
+                 'hashref'  => { 'billpkgnum' => $self->billpkgnum },
+                 'order_by' => 'ORDER BY detailnum',
+              });
+
+  } elsif ( $opt{'no_usage'} ) {
+
+    my $sql = "SELECT detail FROM ". $self->detail_table.
+              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "    AND ( format IS NULL OR format != 'C' ) ".
+              "  ORDER BY detailnum";
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+
+    map &{$escape_function}( $_->[0] ), @{ $sth->fetchall_arrayref };
+
+  } else {
+
+    my $format_sub;
+    my $format = $opt{format} || '';
+    if ( $format eq 'html' ) {
+
+      $format_sub = sub { my $detail = shift;
+                          $csv->parse($detail) or return "can't parse $detail";
+                          join('</TD><TD>', map { &$escape_function($_) }
+                                            $csv->fields
+                              );
+                        };
+
+    } elsif ( $format eq 'latex' ) {
+
+      $format_sub = sub {
+        my $detail = shift;
+        $csv->parse($detail) or return "can't parse $detail";
+        #join(' & ', map { '\small{'. &$escape_function($_). '}' }
+        #            $csv->fields );
+        my $result = '';
+        my $column = 1;
+        foreach ($csv->fields) {
+          $result .= ' & ' if $column > 1;
+          if ($column > 6) {                     # KLUDGE ALERT!
+            $result .= '\multicolumn{1}{l}{\scriptsize{'.
+                       &$escape_function($_). '}}';
+          }else{
+            $result .= '\scriptsize{'.  &$escape_function($_). '}';
+          }
+          $column++;
+        }
+        $result;
+      };
+
+    } else {
+
+      $format_sub = sub { my $detail = shift;
+                          $csv->parse($detail) or return "can't parse $detail";
+                          join(' - ', map { &$escape_function($_) }
+                                      $csv->fields
+                              );
+                        };
+
+    }
+
+    my $sql = "SELECT format, detail FROM ". $self->detail_table.
+              "  WHERE billpkgnum = ". $self->billpkgnum.
+              "  ORDER BY detailnum";
+    my $sth = dbh->prepare($sql) or die dbh->errstr;
+    $sth->execute or die $sth->errstr;
+
+    #avoid the fetchall_arrayref and loop for less memory usage?
+
+    map { (defined($_->[0]) && $_->[0] eq 'C')
+            ? &{$format_sub}(      $_->[1] )
+            : &{$escape_function}( $_->[1] );
+        }
+      @{ $sth->fetchall_arrayref };
+
+  }
+
+}
+
+=item details_header [ OPTION => VALUE ... ]
+
+Returns a list representing an invoice line item detail header, if any.
+This relies on the behavior of voip_cdr in that it expects the header
+to be the first CSV formatted detail (as is expected by invoice generation
+routines).  Returns the empty list otherwise.
+
+=cut
+
+sub details_header {
+  my $self = shift;
+
+  my $csv = new Text::CSV_XS;
+
+  my @detail = 
+    qsearch ({ 'table'    => $self->detail_table,
+               'hashref'  => { 'billpkgnum' => $self->billpkgnum,
+                               'format'     => 'C',
+                             },
+               'order_by' => 'ORDER BY detailnum LIMIT 1',
+            });
+  return() unless scalar(@detail);
+  $csv->parse($detail[0]->detail) or return ();
+  $csv->fields;
+}
+
+=item quantity
+
+=cut
+
+sub quantity {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('quantity', $value);
+  }
+  $self->getfield('quantity') || 1;
+}
+
+=item unitsetup
+
+=cut
+
+sub unitsetup {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('unitsetup', $value);
+  }
+  $self->getfield('unitsetup') eq ''
+    ? $self->getfield('setup')
+    : $self->getfield('unitsetup');
+}
+
+=item unitrecur
+
+=cut
+
+sub unitrecur {
+  my( $self, $value ) = @_;
+  if ( defined($value) ) {
+    $self->setfield('unitrecur', $value);
+  }
+  $self->getfield('unitrecur') eq ''
+    ? $self->getfield('recur')
+    : $self->getfield('unitrecur');
+}
+
+=item cust_bill_pkg_display [ type => TYPE ]
+
+Returns an array of display information for the invoice line item optionally
+limited to 'TYPE'.
+
+=cut
+
+sub cust_bill_pkg_display {
+  my ( $self, %opt ) = @_;
+
+  my $class = 'FS::'. $self->display_table;
+
+  my $default = $class->new( { billpkgnum =>$self->billpkgnum } );
+
+  my $type = $opt{type} if exists $opt{type};
+  my @result;
+
+  if ( $self->get('display') ) {
+    @result = grep { defined($type) ? ($type eq $_->type) : 1 }
+              @{ $self->get('display') };
+  } else {
+    my $hashref = { 'billpkgnum' => $self->billpkgnum };
+    $hashref->{type} = $type if defined($type);
+    
+    @result = qsearch ({ 'table'    => $self->display_table,
+                         'hashref'  => { 'billpkgnum' => $self->billpkgnum },
+                         'order_by' => 'ORDER BY billpkgdisplaynum',
+                      });
+  }
+
+  push @result, $default unless ( scalar(@result) || $type );
+
+  @result;
+
+}
+
+=item cust_bill_pkg_detail [ CLASSNUM ]
+
+Returns the list of associated cust_bill_pkg_detail objects
+The optional CLASSNUM argument will limit the details to the specified usage
+class.
+
+=cut
+
+sub cust_bill_pkg_detail {
+  my $self = shift;
+  my $classnum = shift || '';
+
+  my %hash = ( 'billpkgnum' => $self->billpkgnum );
+  $hash{classnum} = $classnum if $classnum;
+
+  qsearch( $self->detail_table, \%hash ),
+
+}
+
+=item cust_bill_pkg_discount 
+
+Returns the list of associated cust_bill_pkg_discount objects.
+
+=cut
+
+sub cust_bill_pkg_discount {
+  my $self = shift;
+  qsearch( $self->discount_table, { 'billpkgnum' => $self->billpkgnum } );
+}
+
+1;
index 61cfccb..d35fd55 100644 (file)
@@ -894,7 +894,6 @@ sub print_generic {
     warn "$me   setting options\n"
       if $DEBUG > 1;
 
-    my $multilocation = scalar($cust_main->cust_location); #too expensive?
     my %options = ();
     $options{'section'} = $section if $multisection;
     $options{'format'} = $format;
@@ -904,7 +903,6 @@ sub print_generic {
     $options{'summary_page'} = $summarypage;
     $options{'skip_usage'} =
       scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
-    $options{'multilocation'} = $multilocation;
     $options{'multisection'} = $multisection;
 
     warn "$me   searching for line items\n"
@@ -2111,8 +2109,6 @@ ignored.
 multisection: a flag indicating that this is a multisection invoice,
 which does something complicated.
 
-multilocation: a flag to display the location label for the package.
-
 Returns a list of hashrefs, each of which may contain:
 
 pkgnum, description, amount, unit_amount, quantity, _is_setup, and 
@@ -2134,13 +2130,13 @@ sub _items_cust_bill_pkg {
   my $unsquelched = $opt{unsquelched} || ''; #unused
   my $section = $opt{section}->{description} if $opt{section};
   my $summary_page = $opt{summary_page} || ''; #unused
-  my $multilocation = $opt{multilocation} || '';
   my $multisection = $opt{multisection} || '';
   my $discount_show_always = 0;
 
   my $maxlength = $conf->config('cust_bill-latex_lineitem_maxlength') || 50;
 
   my $cust_main = $self->cust_main;#for per-agent cust_bill-line_item-ate_style
+                                   # and location labels
 
   my @b = ();
   my ($s, $r, $u) = ( undef, undef, undef );
@@ -2255,7 +2251,7 @@ sub _items_cust_bill_pkg {
                          $cust_pkg->h_labels_short($self->_date, undef, 'I')
               unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
 
-            if ( $multilocation ) {
+            if ( $cust_pkg->locationnum != $cust_main->ship_locationnum  ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -2357,7 +2353,7 @@ sub _items_cust_bill_pkg {
             warn "$me _items_cust_bill_pkg done adding service details\n"
               if $DEBUG > 1;
 
-            if ( $multilocation ) {
+            if ( $cust_pkg->locationnum != $cust_main->ship_locationnum ) {
               my $loc = $cust_pkg->location_label;
               $loc = substr($loc, 0, $maxlength). '...'
                 if $format eq 'latex' && length($loc) > $maxlength;
@@ -2411,6 +2407,10 @@ sub _items_cust_bill_pkg {
             $amount = $cust_bill_pkg->usage;
           }
   
+          my $unit_amount =
+            ( $cust_bill_pkg->unitrecur > 0 ) ? $cust_bill_pkg->unitrecur
+                                              : $amount;
+
           if ( !$type || $type eq 'R' ) {
 
             warn "$me _items_cust_bill_pkg adding recur\n"
@@ -2418,7 +2418,7 @@ sub _items_cust_bill_pkg {
 
             if ( $cust_bill_pkg->hidden ) {
               $r->{amount}      += $amount;
-              $r->{unit_amount} += $cust_bill_pkg->unitrecur;
+              $r->{unit_amount} += $unit_amount;
               push @{ $r->{ext_description} }, @d;
             } else {
               $r = {
@@ -2427,7 +2427,7 @@ sub _items_cust_bill_pkg {
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
-                unit_amount     => $cust_bill_pkg->unitrecur,
+                unit_amount     => $unit_amount,
                 quantity        => $cust_bill_pkg->quantity,
                 %item_dates,
                 ext_description => \@d,
@@ -2442,7 +2442,7 @@ sub _items_cust_bill_pkg {
 
             if ( $cust_bill_pkg->hidden ) {
               $u->{amount}      += $amount;
-              $u->{unit_amount} += $cust_bill_pkg->unitrecur;
+              $u->{unit_amount} += $unit_amount,
               push @{ $u->{ext_description} }, @d;
             } else {
               $u = {
@@ -2451,7 +2451,7 @@ sub _items_cust_bill_pkg {
                 pkgnum          => $cust_bill_pkg->pkgnum,
                 amount          => $amount,
                 recur_show_zero => $cust_bill_pkg->recur_show_zero,
-                unit_amount     => $cust_bill_pkg->unitrecur,
+                unit_amount     => $unit_amount,
                 quantity        => $cust_bill_pkg->quantity,
                 %item_dates,
                 ext_description => \@d,
index e2dfce3..01e2e29 100644 (file)
@@ -50,7 +50,7 @@ sub access_right {
 sub session {
   my( $self, $session ) = @_;
 
-  if ( $session && $session->{'Current_User'} ) { # does this even work?
+  if ( $session && $session->{'CurrentUser'} ) { # does this even work?
     warn "$me session: using existing session and CurrentUser: \n".
          Dumper($session->{'CurrentUser'})
       if $DEBUG;
@@ -92,6 +92,7 @@ sub init {
   # this needs to be done on each fork
   warn "$me init: initializing RT\n" if $DEBUG;
   {
+    local $SIG{__WARN__};
     local $SIG{__DIE__};
     eval 'RT::Init("NoSignalHandlers"=>1);';
   }
diff --git a/FS/FS/Trace.pm b/FS/FS/Trace.pm
new file mode 100644 (file)
index 0000000..9ff39dd
--- /dev/null
@@ -0,0 +1,35 @@
+package FS::Trace;
+
+use strict;
+use Date::Format;
+use File::Slurp;
+
+my @trace = ();
+
+sub log {
+  my( $class, $msg ) = @_;
+  push @trace, [ time, "[$$][". time2str('%r', time). "] $msg" ];
+}
+
+sub total {
+  $trace[-1]->[0] - $trace[0]->[0];
+}
+
+sub reset {
+  @trace = ();
+}
+
+sub dump_ary {
+  map $_->[1], @trace;
+}
+
+sub dump {
+  join("\n", map $_->[1], @trace). "\n";
+}
+
+sub dumpfile {
+  my( $class, $filename, $header ) = @_;
+  write_file( $filename, "$header\n". $class->dump );
+}
+
+1;
index 417b202..3f76f51 100644 (file)
@@ -70,6 +70,20 @@ sub upgrade_config {
     foreach grep { ! $conf->exists($_) && -s "$DIST_CONF/$_" }
       qw( quotation_html quotation_latex quotation_latexnotes );
 
+  # change 'fslongtable' to 'longtable'
+  # in invoice and quotation main templates, and also in all secondary 
+  # invoice templates
+  my @latex_confs =
+    qsearch('conf', { 'name' => {op=>'LIKE', value=>'%latex%'} });
+
+  foreach my $c (@latex_confs) {
+    my $value = $c->value;
+    if (length($value) and $value =~ /fslongtable/) {
+      $value =~ s/fslongtable/longtable/g;
+      $conf->set($c->name, $value, $c->agentnum);
+    }
+  }
+
 }
 
 sub upgrade_overlimit_groups {
@@ -278,6 +292,12 @@ sub upgrade_data {
 
     #set up payment gateways if needed
     'pay_batch' => [],
+
+    #flag monthly tax exemptions
+    'cust_tax_exempt_pkg' => [],
+
+    #kick off tax location history upgrade
+    'cust_bill_pkg' => [],
   ;
 
   \%hash;
index e6266b4..397b456 100644 (file)
@@ -152,6 +152,8 @@ sub _upgrade_data { # class method
     'Process payment' => [ 'Process credit card payment', 'Process Echeck payment' ],
     'Post refund'     => [ 'Post check refund', 'Post cash refund' ],
     'Refund payment'  => [ 'Refund credit card payment', 'Refund Echeck payment' ],
+    'Regular void'    => [ 'Void payments' ],
+    'Unvoid'          => [ 'Unvoid payments', 'Unvoid invoices' ],
   );
 
   foreach my $oldright (keys %migrate) {
@@ -174,9 +176,10 @@ sub _upgrade_data { # class method
         die $error if $error;
       }
 
-      #after the WEST stuff is sorted, etc.
-      #my $error = $old->delete;
-      #die $error if $error;
+      unless ( $oldright =~ / (payment|refund)$/ ) { #after the WEST stuff is sorted
+        my $error = $old->delete;
+        die $error if $error;
+      }
 
     }
 
@@ -193,6 +196,8 @@ sub _upgrade_data { # class method
     'Suspend customer package'            => 'Suspend customer',
     'Unsuspend customer package'          => 'Unsuspend customer',
     'New prospect'                        => 'Generate quotation',
+    'Delete invoices'                     => 'Void invoices',
+    'List invoices'                       => 'List quotations',
 
     'List services'    => [ 'Services: Accounts',
                             'Services: Domains',
@@ -261,7 +266,7 @@ sub _upgrade_data { # class method
           'rightname'   => 'Download report data',
       } );
       my $error = $access_right->insert;
-      die $error if $error;
+      warn $error if $error;
     }
 
     FS::upgrade_journal->set_done('ACL_download_report_data');
index e00f587..686bdbd 100755 (executable)
@@ -223,43 +223,45 @@ sub cidr {
   $self->NetAddr->cidr;
 }
 
-=item free_addrs
+=item next_free_addr
 
 Returns a NetAddr::IP object corresponding to the first unassigned address 
 in the block (other than the network, broadcast, or gateway address).  If 
 there are no free addresses, returns nothing.  There are never free addresses
 when manual_flag is true.
 
-=item next_free_addr
-
-Returns a NetAddr::IP object for the first unassigned address in the block,
-or '' if there are none.
+There is no longer a method to return all free addresses in a block.
 
 =cut
 
-sub free_addrs {
+sub next_free_addr {
   my $self = shift;
+  my $selfaddr = $self->NetAddr;
 
   return if $self->manual_flag;
 
   my $conf = new FS::Conf;
   my @excludeaddr = $conf->config('exclude_ip_addr');
-  
+
   my %used = map { $_ => 1 }
   (
+    @excludeaddr,
+    $selfaddr->addr,
+    $selfaddr->network->addr,
+    $selfaddr->broadcast->addr,
     (map { $_->NetAddr->addr }
-      ($self,
-       qsearch('svc_broadband', { blocknum => $self->blocknum }))
+       qsearch('svc_broadband', { blocknum => $self->blocknum })
     ), @excludeaddr
   );
 
-  grep { !$used{$_->addr} } $self->NetAddr->hostenum;
-
-}
+  # just do a linear search of the block
+  my $freeaddr = $selfaddr->network + 1;
+  while ( $freeaddr < $selfaddr->broadcast ) {
+    return $freeaddr unless $used{ $freeaddr->addr };
+    $freeaddr++;
+  }
+  return;
 
-sub next_free_addr {
-  my $self = shift;
-  ($self->free_addrs, '')[0]
 }
 
 =item allocate -- deprecated
diff --git a/FS/FS/agent_pkg_class.pm b/FS/FS/agent_pkg_class.pm
new file mode 100644 (file)
index 0000000..1683c1a
--- /dev/null
@@ -0,0 +1,117 @@
+package FS::agent_pkg_class;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+
+=head1 NAME
+
+FS::agent_pkg_class - Object methods for agent_pkg_class records
+
+=head1 SYNOPSIS
+
+  use FS::agent_pkg_class;
+
+  $record = new FS::agent_pkg_class \%hash;
+  $record = new FS::agent_pkg_class { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::agent_pkg_class object represents an commission for a specific agent
+and package class.  FS::agent_pkg_class inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item agentpkgclassnum
+
+primary key
+
+=item agentnum
+
+agentnum
+
+=item classnum
+
+classnum
+
+=item commission_percent
+
+commission_percent
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'agent_pkg_class'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  $self->commission_percent(0) unless length($self->commission_percent);
+
+  my $error = 
+    $self->ut_numbern('agentpkgclassnum')
+    || $self->ut_foreign_key('agentnum', 'agent', 'agentnum')
+    || $self->ut_foreign_keyn('classnum', 'pkg_class', 'classnum')
+    || $self->ut_float('commission_percent')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 3a6b01b..05179f2 100644 (file)
@@ -478,6 +478,80 @@ sub set_status_and_rated_price {
   }
 }
 
+=item parse_number [ OPTION => VALUE ... ]
+
+Returns two scalars, the countrycode and the rest of the number.
+
+Options are passed as name-value pairs.  Currently available options are:
+
+=over 4
+
+=item column
+
+The column containing the number to be parsed.  Defaults to dst.
+
+=item international_prefix
+
+The digits for international dialing.  Defaults to '011'  The value '+' is
+always recognized.
+
+=item domestic_prefix
+
+The digits for domestic long distance dialing.  Defaults to '1'
+
+=back
+
+=cut
+
+sub parse_number {
+  my ($self, %options) = @_;
+
+  my $field = $options{column} || 'dst';
+  my $intl = $options{international_prefix} || '011';
+  my $countrycode = '';
+  my $number = $self->$field();
+
+  my $to_or_from = 'concerning';
+  $to_or_from = 'from' if $field eq 'src';
+  $to_or_from = 'to' if $field eq 'dst';
+  warn "parsing call $to_or_from $number\n" if $DEBUG;
+
+  #remove non-phone# stuff and whitespace
+  $number =~ s/\s//g;
+#          my $proto = '';
+#          $dest =~ s/^(\w+):// and $proto = $1; #sip:
+#          my $siphost = '';
+#          $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
+
+  if (    $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
+       || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
+     )
+  {
+
+    my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
+    #first look for 1 digit country code
+    if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
+      $countrycode = $one;
+      $number = $u1.$u2.$rest;
+    } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
+      $countrycode = $two;
+      $number = $u2.$rest;
+    } else { #3 digit country code
+      $countrycode = $three;
+      $number = $rest;
+    }
+
+  } else {
+    my $domestic_prefix =
+      exists($options{domestic_prefix}) ? $options{domestic_prefix} : '';
+    $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
+    $number =~ s/^$countrycode//;# if length($number) > 10;
+  }
+
+  return($countrycode, $number);
+
+}
+
 =item rate [ OPTION => VALUE ... ]
 
 Rates this CDR according and sets the status to 'rated'.
@@ -557,51 +631,22 @@ sub rate_prefix {
   # (or calling station id for toll free calls)
   ###
 
-  my( $to_or_from, $number );
+  my( $to_or_from, $column );
   if ( $self->is_tollfree && ! $part_pkg->option_cacheable('disable_tollfree') )
   { #tollfree call
     $to_or_from = 'from';
-    $number = $self->src;
+    $column = 'src';
   } else { #regular call
     $to_or_from = 'to';
-    $number = $self->dst;
+    $column = 'dst';
   }
 
-  warn "parsing call $to_or_from $number\n" if $DEBUG;
-
-  #remove non-phone# stuff and whitespace
-  $number =~ s/\s//g;
-#          my $proto = '';
-#          $dest =~ s/^(\w+):// and $proto = $1; #sip:
-#          my $siphost = '';
-#          $dest =~ s/\@(.*)$// and $siphost = $1; # @10.54.32.1, @sip.example.com
-
   #determine the country code
-  my $intl = $part_pkg->option_cacheable('international_prefix') || '011';
-  my $countrycode = '';
-  if (    $number =~ /^$intl(((\d)(\d))(\d))(\d+)$/
-       || $number =~ /^\+(((\d)(\d))(\d))(\d+)$/
-     )
-  {
-
-    my( $three, $two, $one, $u1, $u2, $rest ) = ( $1,$2,$3,$4,$5,$6 );
-    #first look for 1 digit country code
-    if ( qsearch('rate_prefix', { 'countrycode' => $one } ) ) {
-      $countrycode = $one;
-      $number = $u1.$u2.$rest;
-    } elsif ( qsearch('rate_prefix', { 'countrycode' => $two } ) ) { #or 2
-      $countrycode = $two;
-      $number = $u2.$rest;
-    } else { #3 digit country code
-      $countrycode = $three;
-      $number = $rest;
-    }
-
-  } else {
-    my $domestic_prefix = $part_pkg->option_cacheable('domestic_prefix');
-    $countrycode = length($domestic_prefix) ? $domestic_prefix : '1';
-    $number =~ s/^$countrycode//;# if length($number) > 10;
-  }
+  my ($countrycode, $number) = $self->parse_number(
+    column => $column,
+    international_prefix => $part_pkg->option_cacheable('international_prefix'),
+    domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+  );
 
   warn "rating call $to_or_from +$countrycode $number\n" if $DEBUG;
   my $pretty_dst = "+$countrycode $number";
@@ -622,12 +667,20 @@ sub rate_prefix {
     # -disregard private or unknown numbers
     # -there is exactly one record in rate_prefix for a given NPANXX
     # -default to interstate if we can't find one or both of the prefixes
-    my $dstprefix = $self->dst;
+    my (undef, $dstprefix) = $self->parse_number(
+      column => 'dst',
+      international_prefix => $part_pkg->option_cacheable('international_prefix'),
+      domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+    );
     $dstprefix =~ /^(\d{6})/;
     $dstprefix = qsearchs('rate_prefix', {   'countrycode' => '1', 
                                                 'npa' => $1, 
                                          }) || '';
-    my $srcprefix = $self->src;
+    my (undef, $srcprefix) = $self->parse_number(
+      column => 'src',
+      international_prefix => $part_pkg->option_cacheable('international_prefix'),
+      domestic_prefix => $part_pkg->option_cacheable('domestic_prefix'),
+    );
     $srcprefix =~ /^(\d{6})/;
     $srcprefix = qsearchs('rate_prefix', {   'countrycode' => '1',
                                              'npa' => $1, 
index 390152a..7ef6d76 100644 (file)
@@ -7,7 +7,7 @@ use FS::cdr qw(_cdr_date_parser_maker);
 @ISA = qw(FS::cdr);
 
 %info = (
-  'name'          => 'Taqua',
+  'name'          => 'Taqua v6.0',
   'weight'        => 130,
   'header'        => 1,
   'import_fields' => [  #some of these are kind arbitrary...
diff --git a/FS/FS/cdr/taqua62.pm b/FS/FS/cdr/taqua62.pm
new file mode 100644 (file)
index 0000000..862018e
--- /dev/null
@@ -0,0 +1,178 @@
+package FS::cdr::taqua62;
+
+use strict;
+use vars qw(@ISA %info $da_rewrite);
+use FS::cdr qw(_cdr_date_parser_maker);
+
+@ISA = qw(FS::cdr);
+
+%info = (
+  'name'          => 'Taqua v6.2',
+  'weight'        => 131,
+  'header'        => 1,
+  'import_fields' => [
+
+    #0
+    '', #Key 
+    '', #InsertTime, irrelevant
+    #RecordType
+    sub {
+      my($cdr, $field, $conf, $hashref) = @_;
+      $hashref->{skiprow} = 1
+        unless ($field == 0 && $cdr->disposition == 100       )  #regular CDR
+            || ($field == 1 && $cdr->lastapp     eq 'acctcode'); #accountcode
+      $cdr->cdrtypenum($field);
+    },
+
+    '',       #RecordVersion
+    '',       #OrigShelfNumber
+    '',       #OrigCardNumber
+    '',       #OrigCircuit
+    '',       #OrigCircuitType
+    'uniqueid',                           #SequenceNumber
+    'sessionnum',                         #SessionNumber
+    #10
+    'src',                                #CallingPartyNumber
+    #CalledPartyNumber
+    sub {
+      my( $cdr, $field, $conf ) = @_;
+      if ( $cdr->calltypenum == 6 && $cdr->cdrtypenum == 0 ) {
+        $cdr->dst("+$field");
+      } else {
+        $cdr->dst($field);
+      }
+    },
+
+    _cdr_date_parser_maker('startdate', 'gmt' => 1),  #CallArrivalTime
+    _cdr_date_parser_maker('enddate', 'gmt' => 1),    #CallCompletionTime
+
+    #Disposition
+    #sub { my($cdr, $d ) = @_; $cdr->disposition( $disposition{$d}): },
+    'disposition',
+                                          #  -1 => '',
+                                          #   0 => '',
+                                          # 100 => '', #regular cdr
+                                          # 101 => '',
+                                          # 102 => '',
+                                          # 103 => '',
+                                          # 104 => '',
+                                          # 105 => '',
+                                          # 201 => '',
+                                          # 203 => '',
+                                          # 204 => '',
+
+    _cdr_date_parser_maker('answerdate', 'gmt' => 1), #DispositionTime
+    '',       #TCAP
+    '',       #OutboundCarrierConnectTime
+    '',       #OutboundCarrierDisconnectTime
+
+    #TermTrunkGroup
+    #it appears channels are actually part of trunk groups, but this data
+    #is interesting and we need a source and destination place to put it
+    'dstchannel',                         #TermTrunkGroup
+
+    #20
+
+    '',       #TermShelfNumber
+    '',       #TermCardNumber
+    '',       #TermCircuit
+    '',       #TermCircuitType
+    'carrierid',                          #OutboundCarrierId
+
+    #BillingNumber
+    #'charged_party',                      
+    sub {
+      my( $cdr, $field, $conf ) = @_;
+
+      #could be more efficient for the no config case, if anyone ever needs that
+      $da_rewrite ||= $conf->config('cdr-taqua-da_rewrite');
+
+      if ( $da_rewrite && $field =~ /\d/ ) {
+        my $rewrite = $da_rewrite;
+        $rewrite =~ s/\s//g;
+        my @rewrite = split(',', $conf->config('cdr-taqua-da_rewrite') );
+        if ( grep { $field eq $_ } @rewrite ) {
+          $cdr->charged_party( $cdr->src() );
+          $cdr->calltypenum(12);
+          return;
+        }
+      }
+      if ( $cdr->is_tollfree ) {        # thankfully this is already available
+        $cdr->charged_party($cdr->dst); # and this
+      } else {
+        $cdr->charged_party($field);
+      }
+    },
+
+    'subscriber',                         #SubscriberName
+    'lastapp',                            #ServiceName
+    '',       #some weirdness #ChargeTime
+    'lastdata',                           #ServiceInformation
+
+    #30
+
+    '',       #FacilityInfo
+    '',             #all 1900-01-01 0#CallTraceTime
+    '',             #all-1#UniqueIndicator
+    '',             #all-1#PresentationIndicator
+    '',             #empty#Pin
+    'calltypenum',                        #CallType
+
+    #nothing below is used by QIS...
+
+    '',           #Balt/empty #OrigRateCenter
+    '',           #Balt/empty #TermRateCenter
+
+    #OrigTrunkGroup
+    #it appears channels are actually part of trunk groups, but this data
+    #is interesting and we need a source and destination place to put it
+    'channel',                            #OrigTrunkGroup
+    'userfield',                                #empty#UserDefined
+
+    #40
+
+    '',             #empty#PseudoDestinationNumber
+    '',             #all-1#PseudoCarrierCode
+    '',             #empty#PseudoANI
+    '',             #all-1#PseudoFacilityInfo
+    '',       #OrigDialedDigits
+    '',             #all-1#OrigOutboundCarrier
+    '',       #IncomingCarrierID
+    'dcontext',                           #JurisdictionInfo
+    '',       #OrigDestDigits
+    '',             #empty#AMALineNumber
+
+    #50
+
+    '',             #empty#AMAslpID
+    '',             #empty#AMADigitsDialedWC
+    '',       #OpxOffHook
+    '',       #OpxOnHook
+    '',       #OrigCalledNumber
+    '',       #RedirectingNumber
+    '',       #RouteAttempts
+    '',       #OrigMGCPTerm
+    '',       #TermMGCPTerm
+    '',       #ReasonCode
+
+    #60
+    
+    '',       #OrigIPCallID
+    '',       #ESAIPTrunkGroup
+    '',       #ESAReason
+    '',       #BearerlessCall
+    '',       #oCodec
+    '',       #tCodec
+    '',       #OrigTrunkGroupNumber
+    '',       #TermTrunkGroupNumber
+    '',       #TermRecord
+    '',       #OrigRoutingIndicator
+
+    #70
+
+    '',       #TermRoutingIndicator
+
+  ],
+);
+
+1;
index c3d48a6..c48c806 100644 (file)
@@ -38,6 +38,7 @@ use FS::cust_bill_batch;
 use FS::cust_bill_pay_pkg;
 use FS::cust_credit_bill_pkg;
 use FS::discount_plan;
+use FS::cust_bill_void;
 use FS::L10N;
 
 $DEBUG = 0;
@@ -203,10 +204,63 @@ sub insert {
 
 }
 
+=item void
+
+Voids this invoice: deletes the invoice and adds a record of the voided invoice
+to the FS::cust_bill_void table (and related tables starting from
+FS::cust_bill_pkg_void).
+
+=cut
+
+sub void {
+  my $self = shift;
+  my $reason = scalar(@_) ? shift : '';
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill_void = new FS::cust_bill_void ( {
+    map { $_ => $self->get($_) } $self->fields
+  } );
+  $cust_bill_void->reason($reason);
+  my $error = $cust_bill_void->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
+    my $error = $cust_bill_pkg->void($reason);
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item delete
 
 This method now works but you probably shouldn't use it.  Instead, apply a
-credit against the invoice.
+credit against the invoice, or use the new void method.
 
 Using this method to delete invoices outright is really, really bad.  There
 would be no record you ever posted this invoice, and there are no check to
@@ -236,11 +290,10 @@ sub delete {
     cust_event
     cust_credit_bill
     cust_bill_pay
-    cust_credit_bill
     cust_pay_batch
     cust_bill_pay_batch
-    cust_bill_pkg
     cust_bill_batch
+    cust_bill_pkg
   )) {
 
     foreach my $linked ( $self->$table() ) {
@@ -380,7 +433,8 @@ sub previous {
   my @cust_bill = sort { $a->_date <=> $b->_date }
     grep { $_->owed != 0 }
       qsearch( 'cust_bill', { 'custnum' => $self->custnum,
-                              '_date'   => { op=>'<', value=>$self->_date },
+                              #'_date'   => { op=>'<', value=>$self->_date },
+                              'invnum'   => { op=>'<', value=>$self->invnum },
                             } ) 
   ;
   foreach ( @cust_bill ) { $total += $_->owed; }
index cadb8a7..cb07050 100644 (file)
@@ -337,6 +337,7 @@ sub calculate_applications {
   # could expand @open above, instead, for a slightly different magic effect
   my @result = ();
   foreach my $apply ( @apply ) {
+    # $apply = [ FS::cust_bill_pkg_tax_location record, amount ]
     my @sub_lines = $apply->[0]->cust_bill_pkg_tax_Xlocation;
     my $amount = $apply->[1];
     warn "applying ". $apply->[1]. " to ". $apply->[0]->desc
@@ -346,6 +347,10 @@ sub calculate_applications {
       my $owed = $subline->owed;
       push @result, [ $apply->[0],
                       sprintf('%.2f', min($amount, $owed) ),
+                      # $subline->primary_key is "billpkgtaxlocationnum"
+                      # or "billpkgtaxratelocationnum"
+                      # This is the ONLY place either of those fields will 
+                      # be set.
                       { $subline->primary_key => $subline->get($subline->primary_key) },
                     ];
       $amount -= $owed;
index 4220d3c..20c8e5a 100644 (file)
@@ -1,13 +1,13 @@
 package FS::cust_bill_pkg;
+use base qw( FS::TemplateItem_Mixin FS::cust_main_Mixin FS::Record );
 
 use strict;
 use vars qw( @ISA $DEBUG $me );
 use Carp;
+use List::Util qw( sum min );
 use Text::CSV_XS;
-use FS::Record qw( qsearch qsearchs dbdef dbh );
-use FS::cust_main_Mixin;
+use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_pkg;
-use FS::part_pkg;
 use FS::cust_bill;
 use FS::cust_bill_pkg_detail;
 use FS::cust_bill_pkg_display;
@@ -18,10 +18,13 @@ use FS::cust_tax_exempt_pkg;
 use FS::cust_bill_pkg_tax_location;
 use FS::cust_bill_pkg_tax_rate_location;
 use FS::cust_tax_adjustment;
-
-use List::Util qw(sum);
-
-@ISA = qw( FS::cust_main_Mixin FS::Record );
+use FS::cust_bill_pkg_void;
+use FS::cust_bill_pkg_detail_void;
+use FS::cust_bill_pkg_display_void;
+use FS::cust_bill_pkg_discount_void;
+use FS::cust_bill_pkg_tax_location_void;
+use FS::cust_bill_pkg_tax_rate_location_void;
+use FS::cust_tax_exempt_pkg_void;
 
 $DEBUG = 0;
 $me = '[FS::cust_bill_pkg]';
@@ -120,6 +123,13 @@ customer object (see L<FS::cust_main>).
 
 sub table { 'cust_bill_pkg'; }
 
+sub detail_table            { 'cust_bill_pkg_detail'; }
+sub display_table           { 'cust_bill_pkg_display'; }
+sub discount_table          { 'cust_bill_pkg_discount'; }
+#sub tax_location_table      { 'cust_bill_pkg_tax_location'; }
+#sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
+#sub tax_exempt_pkg_table    { 'cust_tax_exempt_pkg'; }
+
 =item insert
 
 Adds this line item to the database.  If there is an error, returns the error,
@@ -180,14 +190,12 @@ sub insert {
     }
   }
 
-  if ( $self->_cust_tax_exempt_pkg ) {
-    foreach my $cust_tax_exempt_pkg ( @{$self->_cust_tax_exempt_pkg} ) {
-      $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
-      $error = $cust_tax_exempt_pkg->insert;
-      if ( $error ) {
-        $dbh->rollback if $oldAutoCommit;
-        return "error inserting cust_tax_exempt_pkg: $error";
-      }
+  foreach my $cust_tax_exempt_pkg ( @{$self->cust_tax_exempt_pkg} ) {
+    $cust_tax_exempt_pkg->billpkgnum($self->billpkgnum);
+    $error = $cust_tax_exempt_pkg->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return "error inserting cust_tax_exempt_pkg: $error";
     }
   }
 
@@ -230,6 +238,75 @@ sub insert {
 
 }
 
+=item void
+
+Voids this line item: deletes the line item and adds a record of the voided
+line item to the FS::cust_bill_pkg_void table (and related tables).
+
+=cut
+
+sub void {
+  my $self = shift;
+  my $reason = scalar(@_) ? shift : '';
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill_pkg_void = new FS::cust_bill_pkg_void ( {
+    map { $_ => $self->get($_) } $self->fields
+  } );
+  $cust_bill_pkg_void->reason($reason);
+  my $error = $cust_bill_pkg_void->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $table (qw(
+    cust_bill_pkg_detail
+    cust_bill_pkg_display
+    cust_bill_pkg_discount
+    cust_bill_pkg_tax_location
+    cust_bill_pkg_tax_rate_location
+    cust_tax_exempt_pkg
+  )) {
+
+    foreach my $linked ( qsearch($table, { billpkgnum=>$self->billpkgnum }) ) {
+
+      my $vclass = 'FS::'.$table.'_void';
+      my $void = $vclass->new( {
+        map { $_ => $linked->get($_) } $linked->fields
+      });
+      my $error = $void->insert || $linked->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+
+    }
+
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
 =item delete
 
 Not recommended.
@@ -253,6 +330,7 @@ sub delete {
   foreach my $table (qw(
     cust_bill_pkg_detail
     cust_bill_pkg_display
+    cust_bill_pkg_discount
     cust_bill_pkg_tax_location
     cust_bill_pkg_tax_rate_location
     cust_tax_exempt_pkg
@@ -389,36 +467,6 @@ sub regularize_details {
   return;
 }
 
-=item cust_pkg
-
-Returns the package (see L<FS::cust_pkg>) for this invoice line item.
-
-=cut
-
-sub cust_pkg {
-  my $self = shift;
-  carp "$me $self -> cust_pkg" if $DEBUG;
-  qsearchs( 'cust_pkg', { 'pkgnum' => $self->pkgnum } );
-}
-
-=item part_pkg
-
-Returns the package definition for this invoice line item.
-
-=cut
-
-sub part_pkg {
-  my $self = shift;
-  if ( $self->pkgpart_override ) {
-    qsearchs('part_pkg', { 'pkgpart' => $self->pkgpart_override } );
-  } else {
-    my $part_pkg;
-    my $cust_pkg = $self->cust_pkg;
-    $part_pkg = $cust_pkg->part_pkg if $cust_pkg;
-    $part_pkg;
-  }
-}
-
 =item cust_bill
 
 Returns the invoice (see L<FS::cust_bill>) for this invoice line item.
@@ -448,173 +496,6 @@ sub previous_cust_bill_pkg {
   });
 }
 
-=item details [ OPTION => VALUE ... ]
-
-Returns an array of detail information for the invoice line item.
-
-Currently available options are: I<format>, I<escape_function> and
-I<format_function>.
-
-If I<format> is set to html or latex then the array members are improved
-for tabular appearance in those environments if possible.
-
-If I<escape_function> is set then the array members are processed by this
-function before being returned.
-
-I<format_function> overrides the normal HTML or LaTeX function for returning
-formatted CDRs.  It can be set to a subroutine which returns an empty list
-to skip usage detail:
-
-  'format_function' => sub { () },
-
-=cut
-
-sub details {
-  my ( $self, %opt ) = @_;
-  my $escape_function = $opt{escape_function} || sub { shift };
-
-  my $csv = new Text::CSV_XS;
-
-  if ( $opt{format_function} ) {
-
-    #this still expects to be passed a cust_bill_pkg_detail object as the
-    #second argument, which is expensive
-    carp "deprecated format_function passed to cust_bill_pkg->details";
-    my $format_sub = $opt{format_function} if $opt{format_function};
-
-    map { ( $_->format eq 'C'
-              ? &{$format_sub}( $_->detail, $_ )
-              : &{$escape_function}( $_->detail )
-          )
-        }
-      qsearch ({ 'table'    => 'cust_bill_pkg_detail',
-                 'hashref'  => { 'billpkgnum' => $self->billpkgnum },
-                 'order_by' => 'ORDER BY detailnum',
-              });
-
-  } elsif ( $opt{'no_usage'} ) {
-
-    my $sql = "SELECT detail FROM cust_bill_pkg_detail ".
-              "  WHERE billpkgnum = ". $self->billpkgnum.
-              "    AND ( format IS NULL OR format != 'C' ) ".
-              "  ORDER BY detailnum";
-    my $sth = dbh->prepare($sql) or die dbh->errstr;
-    $sth->execute or die $sth->errstr;
-
-    map &{$escape_function}( $_->[0] ), @{ $sth->fetchall_arrayref };
-
-  } else {
-
-    my $format_sub;
-    my $format = $opt{format} || '';
-    if ( $format eq 'html' ) {
-
-      $format_sub = sub { my $detail = shift;
-                          $csv->parse($detail) or return "can't parse $detail";
-                          join('</TD><TD>', map { &$escape_function($_) }
-                                            $csv->fields
-                              );
-                        };
-
-    } elsif ( $format eq 'latex' ) {
-
-      $format_sub = sub {
-        my $detail = shift;
-        $csv->parse($detail) or return "can't parse $detail";
-        #join(' & ', map { '\small{'. &$escape_function($_). '}' }
-        #            $csv->fields );
-        my $result = '';
-        my $column = 1;
-        foreach ($csv->fields) {
-          $result .= ' & ' if $column > 1;
-          if ($column > 6) {                     # KLUDGE ALERT!
-            $result .= '\multicolumn{1}{l}{\scriptsize{'.
-                       &$escape_function($_). '}}';
-          }else{
-            $result .= '\scriptsize{'.  &$escape_function($_). '}';
-          }
-          $column++;
-        }
-        $result;
-      };
-
-    } else {
-
-      $format_sub = sub { my $detail = shift;
-                          $csv->parse($detail) or return "can't parse $detail";
-                          join(' - ', map { &$escape_function($_) }
-                                      $csv->fields
-                              );
-                        };
-
-    }
-
-    my $sql = "SELECT format, detail FROM cust_bill_pkg_detail ".
-              "  WHERE billpkgnum = ". $self->billpkgnum.
-              "  ORDER BY detailnum";
-    my $sth = dbh->prepare($sql) or die dbh->errstr;
-    $sth->execute or die $sth->errstr;
-
-    #avoid the fetchall_arrayref and loop for less memory usage?
-
-    map { (defined($_->[0]) && $_->[0] eq 'C')
-            ? &{$format_sub}(      $_->[1] )
-            : &{$escape_function}( $_->[1] );
-        }
-      @{ $sth->fetchall_arrayref };
-
-  }
-
-}
-
-=item details_header [ OPTION => VALUE ... ]
-
-Returns a list representing an invoice line item detail header, if any.
-This relies on the behavior of voip_cdr in that it expects the header
-to be the first CSV formatted detail (as is expected by invoice generation
-routines).  Returns the empty list otherwise.
-
-=cut
-
-sub details_header {
-  my $self = shift;
-  return '' unless defined dbdef->table('cust_bill_pkg_detail');
-
-  my $csv = new Text::CSV_XS;
-
-  my @detail = 
-    qsearch ({ 'table'    => 'cust_bill_pkg_detail',
-               'hashref'  => { 'billpkgnum' => $self->billpkgnum,
-                               'format'     => 'C',
-                             },
-               'order_by' => 'ORDER BY detailnum LIMIT 1',
-            });
-  return() unless scalar(@detail);
-  $csv->parse($detail[0]->detail) or return ();
-  $csv->fields;
-}
-
-=item desc
-
-Returns a description for this line item.  For typical line items, this is the
-I<pkg> field of the corresponding B<FS::part_pkg> object (see L<FS::part_pkg>).
-For one-shot line items and named taxes, it is the I<itemdesc> field of this
-line item, and for generic taxes, simply returns "Tax".
-
-=cut
-
-sub desc {
-  my $self = shift;
-
-  if ( $self->pkgnum > 0 ) {
-    $self->itemdesc || $self->part_pkg->pkg;
-  } else {
-    my $desc = $self->itemdesc || 'Tax';
-    $desc .= ' '. $self->itemcomment if $self->itemcomment =~ /\S/;
-    $desc;
-  }
-}
-
 =item owed_setup
 
 Returns the amount owed (still outstanding) on this line item's setup fee,
@@ -692,45 +573,6 @@ sub units {
   $self->pkgnum ? $self->part_pkg->calc_units($self->cust_pkg) : 0; # 1?
 }
 
-=item quantity
-
-=cut
-
-sub quantity {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('quantity', $value);
-  }
-  $self->getfield('quantity') || 1;
-}
-
-=item unitsetup
-
-=cut
-
-sub unitsetup {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('unitsetup', $value);
-  }
-  $self->getfield('unitsetup') eq ''
-    ? $self->getfield('setup')
-    : $self->getfield('unitsetup');
-}
-
-=item unitrecur
-
-=cut
-
-sub unitrecur {
-  my( $self, $value ) = @_;
-  if ( defined($value) ) {
-    $self->setfield('unitrecur', $value);
-  }
-  $self->getfield('unitrecur') eq ''
-    ? $self->getfield('recur')
-    : $self->getfield('unitrecur');
-}
 
 =item set_display OPTION => VALUE ...
 
@@ -942,50 +784,10 @@ sub usage_classes {
 
 }
 
-=item cust_bill_pkg_display [ type => TYPE ]
-
-Returns an array of display information for the invoice line item optionally
-limited to 'TYPE'.
-
-=cut
-
-sub cust_bill_pkg_display {
-  my ( $self, %opt ) = @_;
-
-  my $default =
-    new FS::cust_bill_pkg_display { billpkgnum =>$self->billpkgnum };
-
-  my $type = $opt{type} if exists $opt{type};
-  my @result;
-
-  if ( $self->get('display') ) {
-    @result = grep { defined($type) ? ($type eq $_->type) : 1 }
-              @{ $self->get('display') };
-  } else {
-    my $hashref = { 'billpkgnum' => $self->billpkgnum };
-    $hashref->{type} = $type if defined($type);
-    
-    @result = qsearch ({ 'table'    => 'cust_bill_pkg_display',
-                         'hashref'  => { 'billpkgnum' => $self->billpkgnum },
-                         'order_by' => 'ORDER BY billpkgdisplaynum',
-                      });
-  }
-
-  push @result, $default unless ( scalar(@result) || $type );
-
-  @result;
-
-}
-
-# reserving this name for my friends FS::{tax_rate|cust_main_county}::taxline
-# and FS::cust_main::bill
-
-sub _cust_tax_exempt_pkg {
+sub cust_tax_exempt_pkg {
   my ( $self ) = @_;
 
-  $self->{Hash}->{_cust_tax_exempt_pkg} or
-  $self->{Hash}->{_cust_tax_exempt_pkg} = [];
-
+  $self->{Hash}->{cust_tax_exempt_pkg} ||= [];
 }
 
 =item cust_bill_pkg_tax_Xlocation
@@ -1007,36 +809,6 @@ sub cust_bill_pkg_tax_Xlocation {
 
 }
 
-=item cust_bill_pkg_detail [ CLASSNUM ]
-
-Returns the list of associated cust_bill_pkg_detail objects
-The optional CLASSNUM argument will limit the details to the specified usage
-class.
-
-=cut
-
-sub cust_bill_pkg_detail {
-  my $self = shift;
-  my $classnum = shift || '';
-
-  my %hash = ( 'billpkgnum' => $self->billpkgnum );
-  $hash{classnum} = $classnum if $classnum;
-
-  qsearch( 'cust_bill_pkg_detail', \%hash ),
-
-}
-
-=item cust_bill_pkg_discount 
-
-Returns the list of associated cust_bill_pkg_discount objects.
-
-=cut
-
-sub cust_bill_pkg_discount {
-  my $self = shift;
-  qsearch( 'cust_bill_pkg_discount', { 'billpkgnum' => $self->billpkgnum } );
-}
-
 =item recur_show_zero
 
 =cut
@@ -1162,6 +934,485 @@ sub credited_sql {
 
 }
 
+sub upgrade_tax_location {
+  # For taxes that were calculated/invoiced before cust_location refactoring
+  # (May-June 2012), there are no cust_bill_pkg_tax_location records unless
+  # they were calculated on a package-location basis.  Create them here, 
+  # along with any necessary cust_location records and any tax exemption 
+  # records.
+
+  my ($class, %opt) = @_;
+  # %opt may include 's' and 'e': start and end date ranges
+  # and 'X': abort on any error, instead of just rolling back changes to 
+  # that invoice
+  my $dbh = dbh;
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+
+  eval {
+    use FS::h_cust_main;
+    use FS::h_cust_bill;
+    use FS::h_part_pkg;
+    use FS::h_cust_main_exemption;
+  };
+
+  local $FS::cust_location::import = 1;
+
+  my $conf = FS::Conf->new; # h_conf?
+  return if $conf->exists('enable_taxproducts'); #don't touch this case
+  my $use_ship = $conf->exists('tax-ship_address');
+
+  my $date_where = '';
+  if ($opt{s}) {
+    $date_where .= " AND cust_bill._date >= $opt{s}";
+  }
+  if ($opt{e}) {
+    $date_where .= " AND cust_bill._date < $opt{e}";
+  }
+
+  my $commit_each_invoice = 1 unless $opt{X};
+
+  # if an invoice has either of these kinds of objects, then it doesn't
+  # need to be upgraded...probably
+  my $sub_has_tax_link = 'SELECT 1 FROM cust_bill_pkg_tax_location'.
+  ' JOIN cust_bill_pkg USING (billpkgnum)'.
+  ' WHERE cust_bill_pkg.invnum = cust_bill.invnum';
+  my $sub_has_exempt = 'SELECT 1 FROM cust_tax_exempt_pkg'.
+  ' JOIN cust_bill_pkg USING (billpkgnum)'.
+  ' WHERE cust_bill_pkg.invnum = cust_bill.invnum'.
+  ' AND exempt_monthly IS NULL';
+
+  my @invnums = map { $_->invnum } qsearch({
+      select => 'cust_bill.invnum',
+      table => 'cust_bill',
+      hashref => {},
+      extra_sql => "WHERE NOT EXISTS($sub_has_tax_link) ".
+                   "AND NOT EXISTS($sub_has_exempt) ".
+                    $date_where,
+  });
+
+  print "Processing ".scalar(@invnums)." invoices...\n";
+
+  my $committed;
+  INVOICE:
+  foreach my $invnum (@invnums) {
+    $committed = 0;
+    print STDERR "Invoice #$invnum\n";
+    my $pre = '';
+    my %pkgpart_taxclass; # pkgpart => taxclass
+    my %pkgpart_exempt_setup;
+    my %pkgpart_exempt_recur;
+    my $h_cust_bill = qsearchs('h_cust_bill',
+      { invnum => $invnum,
+        history_action => 'insert' });
+    if (!$h_cust_bill) {
+      warn "no insert record for invoice $invnum; skipped\n";
+      #$date = $cust_bill->_date as a fallback?
+      # We're trying to avoid using non-real dates (-d/-y invoice dates)
+      # when looking up history records in other tables.
+      next INVOICE;
+    }
+    my $custnum = $h_cust_bill->custnum;
+
+    # Determine the address corresponding to this tax region.
+    # It's either the bill or ship address of the customer as of the
+    # invoice date-of-insertion.  (Not necessarily the invoice date.)
+    my $date = $h_cust_bill->history_date;
+    my $h_cust_main = qsearchs('h_cust_main',
+        { custnum => $custnum },
+        FS::h_cust_main->sql_h_searchs($date)
+      );
+    if (!$h_cust_main ) {
+      warn "no historical address for cust#".$h_cust_bill->custnum."; skipped\n";
+      next INVOICE;
+      # fallback to current $cust_main?  sounds dangerous.
+    }
+
+    # This is a historical customer record, so it has a historical address.
+    # If there's no cust_location matching this custnum and address (there 
+    # probably isn't), create one.
+    $pre = 'ship_' if $use_ship and length($h_cust_main->get('ship_last'));
+    my %hash = map { $_ => $h_cust_main->get($pre.$_) }
+                  FS::cust_main->location_fields;
+    # not really needed for this, and often result in duplicate locations
+    delete @hash{qw(censustract censusyear latitude longitude coord_auto)};
+
+    $hash{custnum} = $h_cust_main->custnum;
+    my $tax_loc = qsearchs('cust_location', \%hash) # unlikely
+                  || FS::cust_location->new({ %hash });
+    if ( !$tax_loc->locationnum ) {
+      $tax_loc->disabled('Y');
+      my $error = $tax_loc->insert;
+      if ( $error ) {
+        warn "couldn't create historical location record for cust#".
+        $h_cust_main->custnum.": $error\n";
+        next INVOICE;
+      }
+    }
+    my $exempt_cust = 1 if $h_cust_main->tax;
+
+    # Get any per-customer taxname exemptions that were in effect.
+    my %exempt_cust_taxname = map {
+      $_->taxname => 1
+    } qsearch('h_cust_main_exemption', { 'custnum' => $custnum },
+      FS::h_cust_main_exemption->sql_h_searchs($date)
+    );
+
+    # classify line items
+    my @tax_items;
+    my %nontax_items; # taxclass => array of cust_bill_pkg
+    foreach my $item ($h_cust_bill->cust_bill_pkg) {
+      my $pkgnum = $item->pkgnum;
+
+      if ( $pkgnum == 0 ) {
+
+        push @tax_items, $item;
+
+      } else {
+        # (pkgparts really shouldn't change, right?)
+        my $h_cust_pkg = qsearchs('h_cust_pkg', { pkgnum => $pkgnum },
+          FS::h_cust_pkg->sql_h_searchs($date)
+        );
+        if ( !$h_cust_pkg ) {
+          warn "no historical package #".$item->pkgpart."; skipped\n";
+          next INVOICE;
+        }
+        my $pkgpart = $h_cust_pkg->pkgpart;
+
+        if (!exists $pkgpart_taxclass{$pkgpart}) {
+          my $h_part_pkg = qsearchs('h_part_pkg', { pkgpart => $pkgpart },
+            FS::h_part_pkg->sql_h_searchs($date)
+          );
+          if ( !$h_part_pkg ) {
+            warn "no historical package def #$pkgpart; skipped\n";
+            next INVOICE;
+          }
+          $pkgpart_taxclass{$pkgpart} = $h_part_pkg->taxclass || '';
+          $pkgpart_exempt_setup{$pkgpart} = 1 if $h_part_pkg->setuptax;
+          $pkgpart_exempt_recur{$pkgpart} = 1 if $h_part_pkg->recurtax;
+        }
+        
+        # mark any exemptions that apply
+        if ( $pkgpart_exempt_setup{$pkgpart} ) {
+          $item->set('exempt_setup' => 1);
+        }
+
+        if ( $pkgpart_exempt_recur{$pkgpart} ) {
+          $item->set('exempt_recur' => 1);
+        }
+
+        my $taxclass = $pkgpart_taxclass{ $pkgpart };
+
+        $nontax_items{$taxclass} ||= [];
+        push @{ $nontax_items{$taxclass} }, $item;
+      }
+    }
+    printf("%d tax items: \$%.2f\n", scalar(@tax_items), map {$_->setup} @tax_items)
+      if @tax_items;
+
+    # Use a variation on the procedure in 
+    # FS::cust_main::Billing::_handle_taxes to identify taxes that apply 
+    # to this bill.
+    my @loc_keys = qw( district city county state country );
+    my %taxhash = map { $_ => $h_cust_main->get($pre.$_) } @loc_keys;
+    my %taxdef_by_name; # by name, and then by taxclass
+    my %est_tax; # by name, and then by taxclass
+    my %taxable_items; # by taxnum, and then an array
+
+    foreach my $taxclass (keys %nontax_items) {
+      my %myhash = %taxhash;
+      my @elim = qw( district city county state );
+      my @taxdefs; # because there may be several with different taxnames
+      do {
+        $myhash{taxclass} = $taxclass;
+        @taxdefs = qsearch('cust_main_county', \%myhash);
+        if ( !@taxdefs ) {
+          $myhash{taxclass} = '';
+          @taxdefs = qsearch('cust_main_county', \%myhash);
+        }
+        $myhash{ shift @elim } = '';
+      } while scalar(@elim) and !@taxdefs;
+
+      print "Class '$taxclass': ". scalar(@{ $nontax_items{$taxclass} }).
+            " items, ". scalar(@taxdefs)." tax defs found.\n";
+      foreach my $taxdef (@taxdefs) {
+        next if $taxdef->tax == 0;
+        $taxdef_by_name{$taxdef->taxname}{$taxdef->taxclass} = $taxdef;
+
+        $taxable_items{$taxdef->taxnum} ||= [];
+        foreach my $orig_item (@{ $nontax_items{$taxclass} }) {
+          # clone the item so that taxdef-dependent changes don't
+          # change it for other taxdefs
+          my $item = FS::cust_bill_pkg->new({ $orig_item->hash });
+
+          # these flags are already set if the part_pkg declares itself exempt
+          $item->set('exempt_setup' => 1) if $taxdef->setuptax;
+          $item->set('exempt_recur' => 1) if $taxdef->recurtax;
+
+          my @new_exempt;
+          my $taxable = $item->setup + $item->recur;
+          # credits
+          # h_cust_credit_bill_pkg?
+          # NO.  Because if these exemptions HAD been created at the time of 
+          # billing, and then a credit applied later, the exemption would 
+          # have been adjusted by the amount of the credit.  So we adjust
+          # the taxable amount before creating the exemption.
+          # But don't deduct the credit from taxable, because the tax was 
+          # calculated before the credit was applied.
+          foreach my $f (qw(setup recur)) {
+            my $credited = FS::Record->scalar_sql(
+              "SELECT SUM(amount) FROM cust_credit_bill_pkg ".
+              "WHERE billpkgnum = ? AND setuprecur = ?",
+              $item->billpkgnum,
+              $f
+            );
+            $item->set($f, $item->get($f) - $credited) if $credited;
+          }
+          my $existing_exempt = FS::Record->scalar_sql(
+            "SELECT SUM(amount) FROM cust_tax_exempt_pkg WHERE ".
+            "billpkgnum = ? AND taxnum = ?",
+            $item->billpkgnum, $taxdef->taxnum
+          ) || 0;
+          $taxable -= $existing_exempt;
+
+          if ( $taxable and $exempt_cust ) {
+            push @new_exempt, { exempt_cust => 'Y',  amount => $taxable };
+            $taxable = 0;
+          }
+          if ( $taxable and $exempt_cust_taxname{$taxdef->taxname} ){
+            push @new_exempt, { exempt_cust_taxname => 'Y', amount => $taxable };
+            $taxable = 0;
+          }
+          if ( $taxable and $item->exempt_setup ) {
+            push @new_exempt, { exempt_setup => 'Y', amount => $item->setup };
+            $taxable -= $item->setup;
+          }
+          if ( $taxable and $item->exempt_recur ) {
+            push @new_exempt, { exempt_recur => 'Y', amount => $item->recur };
+            $taxable -= $item->recur;
+          }
+
+          $item->set('taxable' => $taxable);
+          push @{ $taxable_items{$taxdef->taxnum} }, $item
+            if $taxable > 0;
+
+          # estimate the amount of tax (this is necessary because different
+          # taxdefs with the same taxname may have different tax rates) 
+          # and sum that for each taxname/taxclass combination
+          # (in cents)
+          $est_tax{$taxdef->taxname} ||= {};
+          $est_tax{$taxdef->taxname}{$taxdef->taxclass} ||= 0;
+          $est_tax{$taxdef->taxname}{$taxdef->taxclass} += 
+            $taxable * $taxdef->tax;
+
+          foreach (@new_exempt) {
+            next if $_->{amount} == 0;
+            my $cust_tax_exempt_pkg = FS::cust_tax_exempt_pkg->new({
+                %$_,
+                billpkgnum  => $item->billpkgnum,
+                taxnum      => $taxdef->taxnum,
+              });
+            my $error = $cust_tax_exempt_pkg->insert;
+            if ($error) {
+              my $pkgnum = $item->pkgnum;
+              warn "error creating tax exemption for inv$invnum pkg$pkgnum:".
+                "\n$error\n\n";
+              next INVOICE;
+            }
+          } #foreach @new_exempt
+        } #foreach $item
+      } #foreach $taxdef
+    } #foreach $taxclass
+
+    # Now go through the billed taxes and match them up with the line items.
+    TAX_ITEM: foreach my $tax_item ( @tax_items )
+    {
+      my $taxname = $tax_item->itemdesc;
+      $taxname = '' if $taxname eq 'Tax';
+
+      if ( !exists( $taxdef_by_name{$taxname} ) ) {
+        # then we didn't find any applicable taxes with this name
+        warn "no definition found for tax item '$taxname'.\n".
+          '('.join(' ', @hash{qw(country state county city district)}).")\n";
+        # possibly all of these should be "next TAX_ITEM", but whole invoices
+        # are transaction protected and we can go back and retry them.
+        next INVOICE;
+      }
+      # classname => cust_main_county
+      my %taxdef_by_class = %{ $taxdef_by_name{$taxname} };
+
+      # Divide the tax item among taxclasses, if necessary
+      # classname => estimated tax amount
+      my $this_est_tax = $est_tax{$taxname};
+      if (!defined $this_est_tax) {
+        warn "no taxable sales found for inv#$invnum, tax item '$taxname'.\n";
+        next INVOICE;
+      }
+      my $est_total = sum(values %$this_est_tax);
+      if ( $est_total == 0 ) {
+        # shouldn't happen
+        warn "estimated tax on invoice #$invnum is zero.\n";
+        next INVOICE;
+      }
+
+      my $real_tax = $tax_item->setup;
+      printf ("Distributing \$%.2f tax:\n", $real_tax);
+      my $cents_remaining = $real_tax * 100; # for rounding error
+      my @tax_links; # partial CBPTL hashrefs
+      foreach my $taxclass (keys %taxdef_by_class) {
+        my $taxdef = $taxdef_by_class{$taxclass};
+        # these items already have "taxable" set to their charge amount
+        # after applying any credits or exemptions
+        my @items = @{ $taxable_items{$taxdef->taxnum} };
+        my $subtotal = sum(map {$_->get('taxable')} @items);
+        printf("\t$taxclass: %.2f\n", $this_est_tax->{$taxclass}/$est_total);
+
+        foreach my $nontax (@items) {
+          my $part = int($real_tax
+                            # class allocation
+                         * ($this_est_tax->{$taxclass}/$est_total) 
+                            # item allocation
+                         * ($nontax->get('taxable'))/$subtotal
+                            # convert to cents
+                         * 100
+                       );
+          $cents_remaining -= $part;
+          push @tax_links, {
+            taxnum => $taxdef->taxnum,
+            pkgnum => $nontax->pkgnum,
+            cents  => $part,
+          };
+        } #foreach $nontax
+      } #foreach $taxclass
+      # Distribute any leftover tax round-robin style, one cent at a time.
+      my $i = 0;
+      my $nlinks = scalar(@tax_links);
+      if ( $nlinks ) {
+        while (int($cents_remaining) > 0) {
+          $tax_links[$i % $nlinks]->{cents} += 1;
+          $cents_remaining--;
+          $i++;
+        }
+      } else {
+        warn "Can't create tax links--no taxable items found.\n";
+        next INVOICE;
+      }
+
+      # Gather credit/payment applications so that we can link them
+      # appropriately.
+      my @unlinked = (
+        qsearch( 'cust_credit_bill_pkg',
+          { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
+        ),
+        qsearch( 'cust_bill_pay_pkg',
+          { billpkgnum => $tax_item->billpkgnum, billpkgtaxlocationnum => '' }
+        )
+      );
+
+      # grab the first one
+      my $this_unlinked = shift @unlinked;
+      my $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
+
+      # Create tax links (yay!)
+      printf("Creating %d tax links.\n",scalar(@tax_links));
+      foreach (@tax_links) {
+        my $link = FS::cust_bill_pkg_tax_location->new({
+            billpkgnum  => $tax_item->billpkgnum,
+            taxtype     => 'FS::cust_main_county',
+            locationnum => $tax_loc->locationnum,
+            taxnum      => $_->{taxnum},
+            pkgnum      => $_->{pkgnum},
+            amount      => sprintf('%.2f', $_->{cents} / 100),
+        });
+        my $error = $link->insert;
+        if ( $error ) {
+          warn "Can't create tax link for inv#$invnum: $error\n";
+          next INVOICE;
+        }
+
+        my $link_cents = $_->{cents};
+        # update/create subitem links
+        #
+        # If $this_unlinked is undef, then we've allocated all of the
+        # credit/payment applications to the tax item.  If $link_cents is 0,
+        # then we've applied credits/payments to all of this package fraction,
+        # so go on to the next.
+        while ($this_unlinked and $link_cents) {
+          # apply as much as possible of $link_amount to this credit/payment
+          # link
+          my $apply_cents = min($link_cents, $unlinked_cents);
+          $link_cents -= $apply_cents;
+          $unlinked_cents -= $apply_cents;
+          # $link_cents or $unlinked_cents or both are now zero
+          $this_unlinked->set('amount' => sprintf('%.2f',$apply_cents/100));
+          $this_unlinked->set('billpkgtaxlocationnum' => $link->billpkgtaxlocationnum);
+          my $pkey = $this_unlinked->primary_key; #creditbillpkgnum or billpaypkgnum
+          if ( $this_unlinked->$pkey ) {
+            # then it's an existing link--replace it
+            $error = $this_unlinked->replace;
+          } else {
+            $this_unlinked->insert;
+          }
+          # what do we do with errors at this stage?
+          if ( $error ) {
+            warn "Error creating tax application link: $error\n";
+            next INVOICE; # for lack of a better idea
+          }
+          
+          if ( $unlinked_cents == 0 ) {
+            # then we've allocated all of this payment/credit application, 
+            # so grab the next one
+            $this_unlinked = shift @unlinked;
+            $unlinked_cents = int($this_unlinked->amount * 100) if $this_unlinked;
+          } elsif ( $link_cents == 0 ) {
+            # then we've covered all of this package tax fraction, so split
+            # off a new application from this one
+            $this_unlinked = $this_unlinked->new({
+                $this_unlinked->hash,
+                $pkey     => '',
+            });
+            # $unlinked_cents is still what it is
+          }
+
+        } #while $this_unlinked and $link_cents
+      } #foreach (@tax_links)
+    } #foreach $tax_item
+
+    $dbh->commit if $commit_each_invoice and $oldAutoCommit;
+    $committed = 1;
+
+  } #foreach $invnum
+  continue {
+    if (!$committed) {
+      $dbh->rollback if $oldAutoCommit;
+      die "Upgrade halted.\n" unless $commit_each_invoice;
+    }
+  }
+
+  $dbh->commit if $oldAutoCommit and !$commit_each_invoice;
+  '';
+}
+
+sub _upgrade_data {
+  # Create a queue job to run upgrade_tax_location from January 1, 2012 to 
+  # the present date.
+  eval {
+    use FS::queue;
+    use Date::Parse 'str2time';
+  };
+  my $class = shift;
+  my $upgrade = 'tax_location_2012';
+  return if FS::upgrade_journal->is_done($upgrade);
+  my $job = FS::queue->new({
+      'job' => 'FS::cust_bill_pkg::upgrade_tax_location'
+  });
+  # call it kind of like a class method, not that it matters much
+  $job->insert($class, 's' => str2time('2012-01-01'));
+  # Then mark the upgrade as done, so that we don't queue the job twice
+  # and somehow run two of them concurrently.
+  FS::upgrade_journal->set_done($upgrade);
+}
+
 =back
 
 =head1 BUGS
@@ -1179,6 +1430,8 @@ owed_setup and owed_recur could then be repaced by just owed, and
 cust_bill::open_cust_bill_pkg and
 cust_bill_ApplicationCommon::apply_to_lineitems could be simplified.
 
+The upgrade procedure is pretty sketchy.
+
 =head1 SEE ALSO
 
 L<FS::Record>, L<FS::cust_bill>, L<FS::cust_pkg>, L<FS::cust_main>, schema.html
diff --git a/FS/FS/cust_bill_pkg_detail_void.pm b/FS/FS/cust_bill_pkg_detail_void.pm
new file mode 100644 (file)
index 0000000..cebe7c1
--- /dev/null
@@ -0,0 +1,168 @@
+package FS::cust_bill_pkg_detail_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::usage_class;
+
+=head1 NAME
+
+FS::cust_bill_pkg_detail_void - Object methods for cust_bill_pkg_detail_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_detail_void;
+
+  $record = new FS::cust_bill_pkg_detail_void \%hash;
+  $record = new FS::cust_bill_pkg_detail_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_detail_void object represents additional detail
+information for a voided invoice line item.  FS::cust_bill_pkg_detail_void
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item detailnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item pkgnum
+
+pkgnum
+
+=item invnum
+
+invnum
+
+=item amount
+
+amount
+
+=item format
+
+format
+
+=item classnum
+
+classnum
+
+=item duration
+
+duration
+
+=item phonenum
+
+phonenum
+
+=item accountcode
+
+accountcode
+
+=item startdate
+
+startdate
+
+=item regionname
+
+regionname
+
+=item detail
+
+detail
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_detail_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('detailnum')
+    || $self->ut_foreign_keyn('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum')
+    || $self->ut_numbern('pkgnum')
+    || $self->ut_numbern('invnum')
+    || $self->ut_floatn('amount')
+    || $self->ut_enum('format', [ '', 'C' ] )
+    || $self->ut_foreign_keyn('classnum', 'usage_class', 'classnum')
+    || $self->ut_numbern('duration')
+    || $self->ut_textn('phonenum')
+    || $self->ut_textn('accountcode')
+    || $self->ut_numbern('startdate')
+    || $self->ut_textn('regionname')
+    || $self->ut_text('detail')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index e7dd5f2..dfa83d3 100644 (file)
@@ -28,8 +28,8 @@ FS::cust_bill_pkg_discount - Object methods for cust_bill_pkg_discount records
 =head1 DESCRIPTION
 
 An FS::cust_bill_pkg_discount object represents the slice of a customer
-applied to a line item.  FS::cust_bill_pkg_discount inherits from
-FS::Record.  The following fields are currently supported:
+discount applied to a specific line item.  FS::cust_bill_pkg_discount inherits
+from FS::Record.  The following fields are currently supported:
 
 =over 4
 
diff --git a/FS/FS/cust_bill_pkg_discount_void.pm b/FS/FS/cust_bill_pkg_discount_void.pm
new file mode 100644 (file)
index 0000000..859ef3c
--- /dev/null
@@ -0,0 +1,129 @@
+package FS::cust_bill_pkg_discount_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::cust_pkg_discount;
+
+=head1 NAME
+
+FS::cust_bill_pkg_discount_void - Object methods for cust_bill_pkg_discount_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_discount_void;
+
+  $record = new FS::cust_bill_pkg_discount_void \%hash;
+  $record = new FS::cust_bill_pkg_discount_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_discount_void object represents the slice of a customer
+discount applied to a specific voided line item.
+FS::cust_bill_pkg_discount_void inherits from FS::Record.  The following fields
+are currently supported:
+
+=over 4
+
+=item billpkgdiscountnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item pkgdiscountnum
+
+pkgdiscountnum
+
+=item amount
+
+amount
+
+=item months
+
+months
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new example.  To add the example to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_discount_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid example.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgdiscountnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_foreign_key('pkgdiscountnum', 'cust_pkg_discount', 'pkgdiscountnum' )
+    || $self->ut_money('amount')
+    || $self->ut_float('months')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_display_void.pm b/FS/FS/cust_bill_pkg_display_void.pm
new file mode 100644 (file)
index 0000000..e78801a
--- /dev/null
@@ -0,0 +1,132 @@
+package FS::cust_bill_pkg_display_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+
+=head1 NAME
+
+FS::cust_bill_pkg_display_void - Object methods for cust_bill_pkg_display_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_display_void;
+
+  $record = new FS::cust_bill_pkg_display_void \%hash;
+  $record = new FS::cust_bill_pkg_display_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_display_void object represents voided line item display
+information.  FS::cust_bill_pkg_display_void inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item billpkgdisplaynum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item section
+
+section
+
+=item post_total
+
+post_total
+
+=item type
+
+type
+
+=item summary
+
+summary
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_display_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgdisplaynum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum')
+    || $self->ut_textn('section')
+    || $self->ut_enum('post_total', [ '', 'Y' ])
+    || $self->ut_enum('type', [ '', 'S', 'R', 'U' ])
+    || $self->ut_enum('summary', [ '', 'Y' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_tax_location_void.pm b/FS/FS/cust_bill_pkg_tax_location_void.pm
new file mode 100644 (file)
index 0000000..9e0794b
--- /dev/null
@@ -0,0 +1,139 @@
+package FS::cust_bill_pkg_tax_location_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::cust_pkg;
+use FS::cust_location;
+
+=head1 NAME
+
+FS::cust_bill_pkg_tax_location_void - Object methods for cust_bill_pkg_tax_location_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_tax_location_void;
+
+  $record = new FS::cust_bill_pkg_tax_location_void \%hash;
+  $record = new FS::cust_bill_pkg_tax_location_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_tax_location_void object represents a voided record
+of taxation based on package location.  FS::cust_bill_pkg_tax_location_void
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item billpkgtaxlocationnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item taxnum
+
+taxnum
+
+=item taxtype
+
+taxtype
+
+=item pkgnum
+
+pkgnum
+
+=item locationnum
+
+locationnum
+
+=item amount
+
+amount
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_tax_location_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgtaxlocationnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_number('taxnum') #cust_bill_pkg/tax_rate key, based on taxtype
+    || $self->ut_enum('taxtype', [ qw( FS::cust_main_county FS::tax_rate ) ] )
+    || $self->ut_foreign_key('pkgnum', 'cust_pkg', 'pkgnum' )
+    || $self->ut_foreign_key('locationnum', 'cust_location', 'locationnum' )
+    || $self->ut_money('amount')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_tax_rate_location_void.pm b/FS/FS/cust_bill_pkg_tax_rate_location_void.pm
new file mode 100644 (file)
index 0000000..f2e85c0
--- /dev/null
@@ -0,0 +1,139 @@
+package FS::cust_bill_pkg_tax_rate_location_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::tax_rate_location;
+
+=head1 NAME
+
+FS::cust_bill_pkg_tax_rate_location_void - Object methods for cust_bill_pkg_tax_rate_location_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_tax_rate_location_void;
+
+  $record = new FS::cust_bill_pkg_tax_rate_location_void \%hash;
+  $record = new FS::cust_bill_pkg_tax_rate_location_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_tax_rate_location_void object represents a voided record
+of taxation based on package location.
+FS::cust_bill_pkg_tax_rate_location_void inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item billpkgtaxratelocationnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item taxnum
+
+taxnum
+
+=item taxtype
+
+taxtype
+
+=item locationtaxid
+
+locationtaxid
+
+=item taxratelocationnum
+
+taxratelocationnum
+
+=item amount
+
+amount
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_tax_rate_location_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgtaxratelocationnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_number('taxnum') #cust_bill_pkg/tax_rate key, based on taxtype
+    || $self->ut_text('taxtype', [ qw( FS::cust_main_county FS::tax_rate ) ] )
+    || $self->ut_textn('locationtaxid')
+    || $self->ut_foreign_key('taxratelocationnum', 'tax_rate_location', 'taxratelocationnum' )
+    || $self->ut_money('amount')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_bill_pkg_tax_rate_location>, L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_pkg_void.pm b/FS/FS/cust_bill_pkg_void.pm
new file mode 100644 (file)
index 0000000..8949ba7
--- /dev/null
@@ -0,0 +1,272 @@
+package FS::cust_bill_pkg_void;
+use base qw( FS::TemplateItem_Mixin FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs dbh fields );
+use FS::cust_bill_void;
+use FS::cust_bill_pkg_detail;
+use FS::cust_bill_pkg_display;
+use FS::cust_bill_pkg_discount;
+use FS::cust_bill_pkg;
+use FS::cust_bill_pkg_tax_location;
+use FS::cust_bill_pkg_tax_rate_location;
+use FS::cust_tax_exempt_pkg;
+
+=head1 NAME
+
+FS::cust_bill_pkg_void - Object methods for cust_bill_pkg_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_pkg_void;
+
+  $record = new FS::cust_bill_pkg_void \%hash;
+  $record = new FS::cust_bill_pkg_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_pkg_void object represents a voided invoice line item.
+FS::cust_bill_pkg_void inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item billpkgnum
+
+primary key
+
+=item invnum
+
+invnum
+
+=item pkgnum
+
+pkgnum
+
+=item pkgpart_override
+
+pkgpart_override
+
+=item setup
+
+setup
+
+=item recur
+
+recur
+
+=item sdate
+
+sdate
+
+=item edate
+
+edate
+
+=item itemdesc
+
+itemdesc
+
+=item itemcomment
+
+itemcomment
+
+=item section
+
+section
+
+=item freq
+
+freq
+
+=item quantity
+
+quantity
+
+=item unitsetup
+
+unitsetup
+
+=item unitrecur
+
+unitrecur
+
+=item hidden
+
+hidden
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_pkg_void'; }
+
+sub detail_table            { 'cust_bill_pkg_detail_void'; }
+sub display_table           { 'cust_bill_pkg_display_void'; }
+sub discount_table          { 'cust_bill_pkg_discount_void'; }
+#sub tax_location_table      { 'cust_bill_pkg_tax_location'; }
+#sub tax_rate_location_table { 'cust_bill_pkg_tax_rate_location'; }
+#sub tax_exempt_pkg_table    { 'cust_tax_exempt_pkg'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item unvoid 
+
+"Un-void"s this line item: Deletes the voided line item from the database and
+adds back a normal line item (and related tables).
+
+=cut
+
+sub unvoid {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill_pkg = new FS::cust_bill_pkg ( {
+    map { $_ => $self->get($_) } fields('cust_bill_pkg')
+  } );
+  my $error = $cust_bill_pkg->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $table (qw(
+    cust_bill_pkg_detail
+    cust_bill_pkg_display
+    cust_bill_pkg_discount
+    cust_bill_pkg_tax_location
+    cust_bill_pkg_tax_rate_location
+    cust_tax_exempt_pkg
+  )) {
+
+    foreach my $voided (
+      qsearch($table.'_void', { billpkgnum=>$self->billpkgnum })
+    ) {
+
+      my $class = 'FS::'.$table;
+      my $unvoid = $class->new( {
+        map { $_ => $voided->get($_) } fields($table)
+      });
+      my $error = $unvoid->insert || $voided->delete;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+
+    }
+
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('billpkgnum')
+    || $self->ut_snumber('pkgnum')
+    || $self->ut_number('invnum') #cust_bill or cust_bill_void, if we ever support line item voiding
+    || $self->ut_numbern('pkgpart_override')
+    || $self->ut_money('setup')
+    || $self->ut_money('recur')
+    || $self->ut_numbern('sdate')
+    || $self->ut_numbern('edate')
+    || $self->ut_textn('itemdesc')
+    || $self->ut_textn('itemcomment')
+    || $self->ut_textn('section')
+    || $self->ut_textn('freq')
+    || $self->ut_numbern('quantity')
+    || $self->ut_moneyn('unitsetup')
+    || $self->ut_moneyn('unitrecur')
+    || $self->ut_enum('hidden', [ '', 'Y' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item cust_bill
+
+Returns the voided invoice (see L<FS::cust_bill_void>) for this voided line
+item.
+
+=cut
+
+sub cust_bill {
+  my $self = shift;
+  #cust_bill or cust_bill_void, if we ever support line item voiding
+  qsearchs( 'cust_bill_void', { 'invnum' => $self->invnum } );
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_bill_void.pm b/FS/FS/cust_bill_void.pm
new file mode 100644 (file)
index 0000000..cce77b3
--- /dev/null
@@ -0,0 +1,286 @@
+package FS::cust_bill_void;
+use base qw( FS::Template_Mixin FS::cust_main_Mixin FS::otaker_Mixin FS::Record );
+
+use strict;
+use FS::Record qw( qsearch qsearchs dbh fields );
+use FS::cust_main;
+use FS::cust_statement;
+use FS::access_user;
+use FS::cust_bill_pkg_void;
+use FS::cust_bill;
+
+=head1 NAME
+
+FS::cust_bill_void - Object methods for cust_bill_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_bill_void;
+
+  $record = new FS::cust_bill_void \%hash;
+  $record = new FS::cust_bill_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_bill_void object represents a voided invoice.  FS::cust_bill_void
+inherits from FS::Record.  The following fields are currently supported:
+
+=over 4
+
+=item invnum
+
+primary key
+
+=item custnum
+
+custnum
+
+=item _date
+
+_date
+
+=item charged
+
+charged
+
+=item invoice_terms
+
+invoice_terms
+
+=item previous_balance
+
+previous_balance
+
+=item billing_balance
+
+billing_balance
+
+=item closed
+
+closed
+
+=item statementnum
+
+statementnum
+
+=item agent_invid
+
+agent_invid
+
+=item promised_date
+
+promised_date
+
+=item void_date
+
+void_date
+
+=item reason
+
+reason
+
+=item void_usernum
+
+void_usernum
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new voided invoice.  To add the voided invoice to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_bill_void'; }
+sub notice_name { 'VOIDED Invoice'; }
+#XXXsub template_conf { 'quotation_'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item unvoid 
+
+"Un-void"s this invoice: Deletes the voided invoice from the database and adds
+back a normal invoice (and related tables).
+
+=cut
+
+sub unvoid {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $cust_bill = new FS::cust_bill ( {
+    map { $_ => $self->get($_) } fields('cust_bill')
+  } );
+  my $error = $cust_bill->insert;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $cust_bill_pkg_void ( $self->cust_bill_pkg ) {
+    my $error = $cust_bill_pkg_void->unvoid;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $error = $self->delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+  '';
+
+}
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid voided invoice.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('invnum')
+    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum' )
+    || $self->ut_numbern('_date')
+    || $self->ut_money('charged')
+    || $self->ut_textn('invoice_terms')
+    || $self->ut_moneyn('previous_balance')
+    || $self->ut_moneyn('billing_balance')
+    || $self->ut_enum('closed', [ '', 'Y' ])
+    || $self->ut_foreign_keyn('statementnum', 'cust_statement', 'statementnum')
+    || $self->ut_numbern('agent_invid')
+    || $self->ut_numbern('promised_date')
+    || $self->ut_numbern('void_date')
+    || $self->ut_textn('reason')
+    || $self->ut_numbern('void_usernum')
+  ;
+  return $error if $error;
+
+  $self->void_date(time) unless $self->void_date;
+
+  $self->void_usernum($FS::CurrentUser::CurrentUser->usernum)
+    unless $self->void_usernum;
+
+  $self->SUPER::check;
+}
+
+=item display_invnum
+
+Returns the displayed invoice number for this invoice: agent_invid if
+cust_bill-default_agent_invid is set and it has a value, invnum otherwise.
+
+=cut
+
+sub display_invnum {
+  my $self = shift;
+  my $conf = $self->conf;
+  if ( $conf->exists('cust_bill-default_agent_invid') && $self->agent_invid ){
+    return $self->agent_invid;
+  } else {
+    return $self->invnum;
+  }
+}
+
+=item void_access_user
+
+Returns the voiding employee object (see L<FS::access_user>).
+
+=cut
+
+sub void_access_user {
+  my $self = shift;
+  qsearchs('access_user', { 'usernum' => $self->void_usernum } );
+}
+
+=item cust_main
+
+=cut
+
+sub cust_main {
+  my $self = shift;
+  qsearchs('cust_main', { 'custnum' => $self->custnum } );
+}
+
+=item cust_bill_pkg
+
+=cut
+
+sub cust_bill_pkg { #actually cust_bill_pkg_void objects
+  my $self = shift;
+  qsearch('cust_bill_pkg_void', { invnum=>$self->invnum });
+}
+
+=back
+
+=item enable_previous
+
+=cut
+
+sub enable_previous { 0 }
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 64f1f29..4189007 100644 (file)
@@ -103,18 +103,22 @@ sub insert {
     return $error;
   }
 
-  my $payable = $self->cust_bill_pkg->payable($self->setuprecur);
-  my $taxable = $self->_is_taxable ? $payable : 0;
-  my $part_pkg = $self->cust_bill_pkg->part_pkg;
-  my $freq = $self->cust_bill_pkg->freq;
+  my $cust_bill_pkg = $self->cust_bill_pkg;
+  #'payable' is the amount charged (either setup or recur)
+  # minus any credit applications, including this one
+  my $payable = $cust_bill_pkg->payable($self->setuprecur);
+  my $part_pkg = $cust_bill_pkg->part_pkg;
+  my $freq = $cust_bill_pkg->freq;
   unless ($freq) {
     $freq = $part_pkg ? ($part_pkg->freq || 1) : 1;#fallback.. assumes unchanged
   }
-  my $taxable_per_month = sprintf("%.2f", $taxable / $freq );
+  my $taxable_per_month = sprintf("%.2f", $payable / $freq );
   my $credit_per_month = sprintf("%.2f", $self->amount / $freq ); #pennies?
 
   if ($taxable_per_month >= 0) {  #panic if its subzero?
-    my $groupby = 'taxnum,year,month';
+    my $groupby = join(',',
+      qw(taxnum year month exempt_monthly exempt_cust 
+         exempt_cust_taxname exempt_setup exempt_recur));
     my $sum = 'SUM(amount)';
     my @exemptions = qsearch(
       {
@@ -124,25 +128,55 @@ sub insert {
         'extra_sql' => "GROUP BY $groupby HAVING $sum > 0",
       }
     ); 
+    # each $exemption is now the sum of all monthly exemptions applied to 
+    # this line item for a particular taxnum and month.
     foreach my $exemption ( @exemptions ) {
-      next if $taxable_per_month >= $exemption->amount;
-      my $amount = $exemption->amount - $taxable_per_month;
-      if ($amount > $credit_per_month) {
-             "cust_bill_pkg ". $self->billpkgnum. "  Reducing.\n";
-        $amount = $credit_per_month;
+      my $amount = 0;
+      if ( $exemption->exempt_monthly ) {
+        # finite exemptions
+        # $taxable_per_month is AFTER inserting the credit application, so 
+        # if it's still larger than the exemption, we don't need to adjust
+        next if $taxable_per_month >= $exemption->amount;
+        # the amount of 'excess' exemption already in place (above the 
+        # remaining charged amount).  We'll de-exempt that much, or the 
+        # amount of the new credit, whichever is smaller.
+        $amount = $exemption->amount - $taxable_per_month;
+        # $amount is the amount of 'excess' exemption already existing 
+        # (above the remaining taxable charge amount).  We'll "de-exempt"
+        # that much, or the amount of the new credit, whichever is smaller.
+        if ($amount > $credit_per_month) {
+               "cust_bill_pkg ". $self->billpkgnum. "  Reducing.\n";
+          $amount = $credit_per_month;
+        }
+      } elsif ( $exemption->exempt_setup or $exemption->exempt_recur ) {
+        # package defined exemptions: may be setup only, recur only, or both
+        my $method = 'exempt_'.$self->setuprecur;
+        if ( $exemption->$method ) {
+          # then it's exempt from the portion of the charge that this 
+          # credit is being applied to
+          $amount = $self->amount;
+        }
+      } else {
+        # other types of exemptions: always equal to the amount of
+        # the charge
+        $amount = $self->amount;
       }
+      next if $amount == 0;
+
+      # create a negative exemption
       my $cust_tax_exempt_pkg = new FS::cust_tax_exempt_pkg {
+         $exemption->hash, # for exempt_ flags, taxnum, month/year
         'billpkgnum'       => $self->billpkgnum,
         'creditbillpkgnum' => $self->creditbillpkgnum,
         'amount'           => sprintf('%.2f', 0-$amount),
-        map { $_ => $exemption->$_ } split(',', $groupby)
       };
+
       my $error = $cust_tax_exempt_pkg->insert;
       if ( $error ) {
         $dbh->rollback if $oldAutoCommit;
         return "error inserting cust_tax_exempt_pkg: $error";
       }
-    }
+    } #foreach $exemption
   }
 
   $dbh->commit or die $dbh->errstr if $oldAutoCommit;
@@ -233,7 +267,7 @@ sub delete {
       return "error calculating taxes: $hashref_or_error";
     }
 
-    push @generated_exemptions, @{ $cust_bill_pkg->_cust_tax_exempt_pkg || [] };
+    push @generated_exemptions, @{ $cust_bill_pkg->cust_tax_exempt_pkg };
   }
                           
   foreach my $taxnum ( keys %seen ) {
index d6a86c7..9e39b30 100644 (file)
@@ -4,6 +4,7 @@ require 5.006;
 use strict;
              #FS::cust_main:_Marketgear when they're ready to move to 2.1
 use base qw( FS::cust_main::Packages FS::cust_main::Status
+             FS::cust_main::NationalID
              FS::cust_main::Billing FS::cust_main::Billing_Realtime
              FS::cust_main::Billing_Discount
              FS::cust_main::Location
@@ -42,6 +43,7 @@ use FS::payby;
 use FS::cust_pkg;
 use FS::cust_svc;
 use FS::cust_bill;
+use FS::cust_bill_void;
 use FS::legacy_cust_bill;
 use FS::cust_pay;
 use FS::cust_pay_pending;
@@ -453,8 +455,10 @@ sub insert {
     warn "  setting $l.custnum\n"
       if $DEBUG > 1;
     my $loc = $self->$l;
-    $loc->set(custnum => $self->custnum);
-    $error ||= $loc->replace;
+    unless ( $loc->custnum ) {
+      $loc->set(custnum => $self->custnum);
+      $error ||= $loc->replace;
+    }
 
     if ( $error ) {
       $dbh->rollback if $oldAutoCommit;
@@ -1242,9 +1246,12 @@ sub merge {
 
   return "Can't merge a customer into self" if $self->custnum == $new_custnum;
 
-  unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
-    return "Invalid new customer number: $new_custnum";
-  }
+  my $new_cust_main = qsearchs( 'cust_main', { 'custnum' => $new_custnum } )
+    or return "Invalid new customer number: $new_custnum";
+
+  return 'Access denied: "Merge customer across agents" access right required to merge into a customer of a different agent'
+    if $self->agentnum != $new_cust_main->agentnum 
+    && ! $FS::CurrentUser::CurrentUser->access_right('Merge customer across agents');
 
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
@@ -1279,6 +1286,7 @@ sub merge {
 
   tie my %financial_tables, 'Tie::IxHash',
     'cust_bill'      => 'invoices',
+    'cust_bill_void' => 'voided invoices',
     'cust_statement' => 'statements',
     'cust_credit'    => 'credits',
     'cust_pay'       => 'payments',
@@ -1779,8 +1787,10 @@ sub check {
     || $self->ut_textn('custbatch')
     || $self->ut_name('last')
     || $self->ut_name('first')
-    || $self->ut_snumbern('birthdate')
     || $self->ut_snumbern('signupdate')
+    || $self->ut_snumbern('birthdate')
+    || $self->ut_snumbern('spouse_birthdate')
+    || $self->ut_snumbern('anniversary_date')
     || $self->ut_textn('company')
     || $self->ut_anything('comments')
     || $self->ut_numbern('referral_custnum')
@@ -1790,6 +1800,7 @@ sub check {
     || $self->ut_floatn('cdr_termination_percentage')
     || $self->ut_floatn('credit_limit')
     || $self->ut_numbern('billday')
+    || $self->ut_numbern('prorate_day')
     || $self->ut_enum('edit_subject', [ '', 'Y' ] )
     || $self->ut_enum('calling_list_exempt', [ '', 'Y' ] )
     || $self->ut_enum('invoice_noemail', [ '', 'Y' ] )
@@ -3644,6 +3655,20 @@ be passed.
 
 =cut
 
+=item cust_bill_void
+
+Returns all the voided invoices (see L<FS::cust_bill_void>) for this customer.
+
+=cut
+
+sub cust_bill_void {
+  my $self = shift;
+
+  map { $_ } #return $self->num_cust_bill_void unless wantarray;
+  sort { $a->_date <=> $b->_date }
+    qsearch( 'cust_bill_void', { 'custnum' => $self->custnum } )
+}
+
 sub cust_statement {
   my $self = shift;
   my $opt = ref($_[0]) ? shift : { @_ };
@@ -3800,7 +3825,7 @@ sub cust_pay_void {
 
 =item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
 
-Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
+Returns all batched payments (see L<FS::cust_pay_batch>) for this customer.
 
 Optionally, a list or hashref of additional arguments to the qsearch call can
 be passed.
index bab94c3..11247a2 100644 (file)
@@ -735,21 +735,25 @@ sub calculate_taxes {
   my @tax_line_items = ();
 
   # keys are tax names (as printed on invoices / itemdesc )
-  # values are listrefs of taxlisthash keys (internal identifiers)
+  # values are arrayrefs of taxlisthash keys (internal identifiers)
   my %taxname = ();
 
   # keys are taxlisthash keys (internal identifiers)
   # values are (cumulative) amounts
-  my %tax = ();
+  my %tax_amount = ();
 
   # keys are taxlisthash keys (internal identifiers)
-  # values are listrefs of cust_bill_pkg_tax_location hashrefs
+  # values are arrayrefs of cust_bill_pkg_tax_location hashrefs
   my %tax_location = ();
 
   # keys are taxlisthash keys (internal identifiers)
-  # values are listrefs of cust_bill_pkg_tax_rate_location hashrefs
+  # values are arrayrefs of cust_bill_pkg_tax_rate_location hashrefs
   my %tax_rate_location = ();
 
+  # keys are taxnums (not internal identifiers!)
+  # values are arrayrefs of cust_tax_exempt_pkg objects
+  my %tax_exemption;
+
   foreach my $tax ( keys %$taxlisthash ) {
     # $tax is a tax identifier
     my $tax_object = shift @{ $taxlisthash->{$tax} };
@@ -759,14 +763,24 @@ sub calculate_taxes {
     warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
     warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
     # taxline calculates the tax on all cust_bill_pkgs in the 
-    # first (arrayref) argument
+    # first (arrayref) argument, and returns a hashref of 'name' 
+    # (the line item description) and 'amount'.
+    # It also calculates exemptions and attaches them to the cust_bill_pkgs
+    # in the argument.
+    my $taxables = $taxlisthash->{$tax};
+    my $exemptions = $tax_exemption{$tax_object->taxnum} ||= [];
     my $hashref_or_error =
-      $tax_object->taxline( $taxlisthash->{$tax},
+      $tax_object->taxline( $taxables,
                             'custnum'      => $self->custnum,
-                            'invoice_time' => $invoice_time
+                            'invoice_time' => $invoice_time,
+                            'exemptions'   => $exemptions,
                           );
     return $hashref_or_error unless ref($hashref_or_error);
 
+    # then collect any new exemptions generated for this tax
+    push @$exemptions, @{ $_->cust_tax_exempt_pkg }
+      foreach @$taxables;
+
     unshift @{ $taxlisthash->{$tax} }, $tax_object;
 
     my $name   = $hashref_or_error->{'name'};
@@ -776,7 +790,7 @@ sub calculate_taxes {
     $taxname{ $name } ||= [];
     push @{ $taxname{ $name } }, $tax;
 
-    $tax{ $tax } += $amount;
+    $tax_amount{ $tax } += $amount;
 
     # link records between cust_main_county/tax_rate and cust_location
     $tax_location{ $tax } ||= [];
@@ -809,17 +823,21 @@ sub calculate_taxes {
   #move the cust_tax_exempt_pkg records to the cust_bill_pkgs we will commit
   my %packagemap = map { $_->pkgnum => $_ } @$cust_bill_pkg;
   foreach my $tax ( keys %$taxlisthash ) {
-    foreach ( @{ $taxlisthash->{$tax} }[1 ... scalar(@{ $taxlisthash->{$tax} })] ) {
-      next unless ref($_) eq 'FS::cust_bill_pkg';
-     
-      my @cust_tax_exempt_pkg = splice( @{ $_->_cust_tax_exempt_pkg } );
+    my $taxables = $taxlisthash->{$tax};
+    my $tax_object = shift @$taxables; # the rest are line items
+    foreach my $cust_bill_pkg ( @$taxables ) {
+      next unless ref($cust_bill_pkg) eq 'FS::cust_bill_pkg';
 
-      next unless @cust_tax_exempt_pkg; #just avoiding the prob when irrelevant?
-      die "can't distribute tax exemptions: no line item for ".  Dumper($_).
-          " in packagemap ". join(',', sort {$a<=>$b} keys %packagemap). "\n"
-        unless $packagemap{$_->pkgnum};
+      my @cust_tax_exempt_pkg = splice @{ $cust_bill_pkg->cust_tax_exempt_pkg };
 
-      push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
+      next unless @cust_tax_exempt_pkg;
+      # get the non-disintegrated version
+      my $real_cust_bill_pkg = $packagemap{$cust_bill_pkg->pkgnum}
+        or die "can't distribute tax exemptions: no line item for ".
+          Dumper($_). " in packagemap ". 
+          join(',', sort {$a<=>$b} keys %packagemap). "\n";
+
+      push @{ $real_cust_bill_pkg->cust_tax_exempt_pkg },
            @cust_tax_exempt_pkg;
     }
   }
@@ -827,15 +845,15 @@ sub calculate_taxes {
   #consolidate and create tax line items
   warn "consolidating and generating...\n" if $DEBUG > 2;
   foreach my $taxname ( keys %taxname ) {
-    my $tax = 0;
+    my $tax_total = 0;
     my %seen = ();
     my @cust_bill_pkg_tax_location = ();
     my @cust_bill_pkg_tax_rate_location = ();
     warn "adding $taxname\n" if $DEBUG > 1;
     foreach my $taxitem ( @{ $taxname{$taxname} } ) {
       next if $seen{$taxitem}++;
-      warn "adding $tax{$taxitem}\n" if $DEBUG > 1;
-      $tax += $tax{$taxitem};
+      warn "adding $tax_amount{$taxitem}\n" if $DEBUG > 1;
+      $tax_total += $tax_amount{$taxitem};
       push @cust_bill_pkg_tax_location,
         map { new FS::cust_bill_pkg_tax_location $_ }
             @{ $tax_location{ $taxitem } };
@@ -843,9 +861,9 @@ sub calculate_taxes {
         map { new FS::cust_bill_pkg_tax_rate_location $_ }
             @{ $tax_rate_location{ $taxitem } };
     }
-    next unless $tax;
+    next unless $tax_total;
 
-    $tax = sprintf('%.2f', $tax );
+    $tax_total = sprintf('%.2f', $tax_total );
   
     my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
                                                    'disabled'     => '',
@@ -866,7 +884,7 @@ sub calculate_taxes {
 
     push @tax_line_items, new FS::cust_bill_pkg {
       'pkgnum'   => 0,
-      'setup'    => $tax,
+      'setup'    => $tax_total,
       'recur'    => 0,
       'sdate'    => '',
       'edate'    => '',
@@ -1197,8 +1215,11 @@ sub _handle_taxes {
   my $exempt = $conf->exists('cust_class-tax_exempt')
                  ? ( $self->cust_class ? $self->cust_class->tax : '' )
                  : $self->tax;
+  # standardize this just to be sure
+  $exempt = ($exempt eq 'Y') ? 'Y' : '';
 
-  if ( $exempt !~ /Y/i && $self->payby ne 'COMP' ) {
+  #if ( $exempt !~ /Y/i && $self->payby ne 'COMP' ) {
+  if ( $self->payby ne 'COMP' ) {
 
     if ( $conf->exists('enable_taxproducts')
          && ( scalar($part_pkg->part_pkg_taxoverride)
@@ -1207,19 +1228,26 @@ sub _handle_taxes {
        )
     {
 
-      foreach my $class (@classes) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg );
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{$class} = $err_or_ref;
-      }
+      if ( !$exempt ) {
+
+        foreach my $class (@classes) {
+          my $err_or_ref = $self->_gather_taxes( $part_pkg, $class, $cust_pkg );
+          return $err_or_ref unless ref($err_or_ref);
+          $taxes{$class} = $err_or_ref;
+        }
+
+        unless (exists $taxes{''}) {
+          my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
+          return $err_or_ref unless ref($err_or_ref);
+          $taxes{''} = $err_or_ref;
+        }
 
-      unless (exists $taxes{''}) {
-        my $err_or_ref = $self->_gather_taxes( $part_pkg, '', $cust_pkg );
-        return $err_or_ref unless ref($err_or_ref);
-        $taxes{''} = $err_or_ref;
       }
 
-    } else {
+    } else { # cust_main_county tax system
+
+      # We fetch taxes even if the customer is completely exempt,
+      # because we need to record that fact.
 
       my @loc_keys = qw( district city county state country );
       my $location = $cust_pkg->tax_location;
@@ -1227,6 +1255,8 @@ sub _handle_taxes {
 
       $taxhash{'taxclass'} = $part_pkg->taxclass;
 
+      warn "taxhash:\n". Dumper(\%taxhash) if $DEBUG > 2;
+
       my @taxes = (); # entries are cust_main_county objects
       my %taxhash_elim = %taxhash;
       my @elim = qw( district city county state );
@@ -1246,17 +1276,11 @@ sub _handle_taxes {
 
       } while ( !scalar(@taxes) && scalar(@elim) );
 
-      @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
-                    @taxes
-        if $self->cust_main_exemption; #just to be safe
-
-      # all packages now have a locationnum and should get a 
-      # cust_bill_pkg_tax_location record.  The tax_locationnum
-      # may be the package's locationnum, or the customer's bill 
-      # or service location.
       foreach (@taxes) {
-        $_->set('pkgnum',      $cust_pkg->pkgnum);
-        $_->set('locationnum', $cust_pkg->tax_locationnum);
+        # These could become cust_bill_pkg_tax_location records,
+        # or cust_tax_exempt_pkg.  We'll decide later.
+        $_->set('pkgnum',       $cust_pkg->pkgnum);
+        $_->set('locationnum',  $cust_pkg->tax_locationnum);
       }
 
       $taxes{''} = [ @taxes ];
@@ -1273,7 +1297,7 @@ sub _handle_taxes {
 
     } #if $conf->exists('enable_taxproducts') ...
 
-  }
+  } # if $self->payby eq 'COMP'
 
   #what's this doing in the middle of _handle_taxes?  probably should split
   #this into three parts above in _make_lines
@@ -1296,14 +1320,15 @@ sub _handle_taxes {
 
       # this is the tax identifier, not the taxname
       my $taxname = ref( $tax ). ' '. $tax->taxnum;
-#      $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
-#                  ' locationnum'. $cust_pkg->locationnum
-#        if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
+      $taxname .= ' pkgnum'. $cust_pkg->pkgnum;
+      # We need to create a separate $taxlisthash entry for each pkgnum
+      # on the invoice, so that cust_bill_pkg_tax_location records will
+      # be linked correctly.
 
-      # $taxlisthash: keys are "setup", "recur", and usage classes
-      # values are arrayrefs, first the tax object (cust_main_county
+      # $taxlisthash: keys are "setup", "recur", and usage classes.
+      # Values are arrayrefs, first the tax object (cust_main_county
       # or tax_rate) and then any cust_bill_pkg objects that the 
-      # tax applies to
+      # tax applies to.
       $taxlisthash->{ $taxname } ||= [ $tax ];
       push @{ $taxlisthash->{ $taxname  } }, $tax_cust_bill_pkg;
 
index 6681f9e..eadcc1a 100644 (file)
@@ -210,8 +210,23 @@ sub batch_import {
                   cust_pkg.pkgpart cust_pkg.bill
                   svc_acct.username svc_acct._password 
                 );
-   push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
-   push @fields, map "svc_hardware.$_", qw(typenum ip_addr hw_addr serial);
+    push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
+    push @fields, map "svc_hardware.$_", qw(typenum ip_addr hw_addr serial);
+
+    $payby = 'BILL';
+  } elsif ( $format eq 'national_id-acct_phone') {
+    @fields = qw( agent_custid refnum
+                  last first company address1 address2 city state zip country
+                  daytime night
+                  ship_last ship_first ship_company ship_address1 ship_address2
+                  ship_city ship_state ship_zip ship_country
+                  national_id
+                  payinfo paycvv paydate
+                  invoicing_list
+                  cust_pkg.pkgpart cust_pkg.bill
+                  svc_acct.username svc_acct._password svc_acct.slipip
+                );
+    push @fields, map "svc_phone.$_", qw(countrycode phonenum sip_password pin);
 
     $payby = 'BILL';
   } else {
@@ -321,7 +336,7 @@ sub batch_import {
           $cust_pkg{$1} = parse_datetime( shift @columns );
         } 
 
-      } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
+      } elsif ( $field =~ /^svc_acct\.(username|_password|slipip)$/ ) {
 
         $svc_x{$1} = shift @columns;
 
@@ -375,7 +390,8 @@ sub batch_import {
     }
 
     $cust_main{$_} = parse_datetime($cust_main{$_})
-      foreach grep $cust_main{$_}, qw( birthdate spouse_birthdate );
+      foreach grep $cust_main{$_},
+        qw( birthdate spouse_birthdate anniversary_date );
 
     my $invoicing_list = $cust_main{'invoicing_list'}
                            ? [ delete $cust_main{'invoicing_list'} ]
diff --git a/FS/FS/cust_main/NationalID.pm b/FS/FS/cust_main/NationalID.pm
new file mode 100644 (file)
index 0000000..a742b76
--- /dev/null
@@ -0,0 +1,64 @@
+package FS::cust_main::NationalID;
+
+use strict;
+use vars qw( $conf );
+use Date::Simple qw( days_in_month );
+use FS::UID;
+
+install_callback FS::UID sub { 
+  $conf = new FS::Conf;
+};
+
+sub set_national_id_from_cgi {
+  my( $self, $cgi ) = @_;
+
+  my $error = '';
+
+  if ( my $id_country = $conf->config('national_id-country') ) {
+    if ( $id_country eq 'MY' ) {
+  
+      if ( $cgi->param('national_id1') =~ /\S/ ) {
+        my $nric = $cgi->param('national_id1');
+        $nric =~ s/\s//g;
+        if ( $nric =~ /^(\d{2})(\d{2})(\d{2})\-?(\d{2})\-?(\d{4})$/ ) {
+          my( $y, $m, $d, $bp, $n ) = ( $1, $2, $3, $4, $5 );
+          $self->national_id( "$y$m$d-$bp-$n" );
+  
+          my @lt = localtime(time);
+          my $year = ( $y <= substr( $lt[5]+1900, -2) ) ? 2000 + $y
+                                                        : 1900 + $y;
+          $error ||= "Illegal NRIC: ". $cgi->param('national_id1')
+            if $m < 1 || $m > 12 || $d < 1 || $d > days_in_month($year, $m);
+            #$bp validation per http://en.wikipedia.org/wiki/National_Registration_Identity_Card_Number_%28Malaysia%29#Second_section:_Birthplace ?  seems like a bad idea, some could be missing or get added
+        } else {
+          $error ||= "Illegal NRIC: ". $cgi->param('national_id1');
+        }
+      } elsif ( $cgi->param('national_id2') =~ /\S/ ) {
+        my $oldic = $cgi->param('national_id2');
+        $oldic =~ s/\s//g;
+
+        # can you please remove validation for "Old IC/Passport:" field, customer
+        # will have other field format like, RF/123456, I/5234234 ...
+        #if ( $oldic =~ /^\w\d{9}$/ ) {
+          $self->national_id($oldic);
+        #} else {
+        #  $error ||= "Illegal Old IC/Passport: ". $cgi->param('national_id2');
+        #}
+
+      } else {
+        $error ||= 'Either NRIC or Old IC/Passport is required';
+      }
+      
+    } else {
+      warn "unknown national_id-country $id_country";
+    }
+  } elsif ( $cgi->param('national_id0') ) {
+    $self->national_id( $cgi->param('national_id0') );
+  }
+
+  $error;
+
+}
+
+1;
+
index b528a68..b07223e 100644 (file)
@@ -85,7 +85,7 @@ sub smart_search {
       'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
                      ' ( '.
                          join(' OR ', map "$_ = '$phonen'",
-                                          qw( daytime night fax )
+                                          qw( daytime night mobile fax )
                              ).
                      ' ) '.
                      " AND $agentnums_sql", #agent virtualization
@@ -457,6 +457,8 @@ HASHREF.  Valid parameters are
 
 =item address
 
+=item zip
+
 =item refnum
 
 =item cancelled_pkgs
@@ -475,6 +477,10 @@ listref of start date, end date
 
 listref of start date, end date
 
+=item anniversary_date
+
+listref of start date, end date
+
 =item payby
 
 listref
@@ -512,6 +518,7 @@ sub search {
     'usernum'       => '',
     'status'        => '',
     'address'       => '',
+    'zip'           => '',
     'paydate_year'  => '',
     'invoice_terms' => '',
     'custbatch'     => '',
@@ -574,6 +581,18 @@ sub search {
     )";
   }
 
+  ##
+  # zipcode
+  ##
+  if ( $params->{'zip'} =~ /\S/ ) {
+    my $zip = dbh->quote($params->{'zip'} . '%');
+    push @where, "EXISTS(
+      SELECT 1 FROM cust_location
+      WHERE cust_location.custnum = cust_main.custnum
+        AND cust_location.zip LIKE $zip
+    )";
+  }
+
   ###
   # refnum
   ###
@@ -617,7 +636,7 @@ sub search {
   # dates
   ##
 
-  foreach my $field (qw( signupdate birthdate spouse_birthdate )) {
+  foreach my $field (qw( signupdate birthdate spouse_birthdate anniversary_date )) {
 
     next unless exists($params->{$field});
 
@@ -779,6 +798,9 @@ sub search {
 
   my @select = (
                  'cust_main.custnum',
+                 # there's a good chance that we'll need these
+                 'cust_main.bill_locationnum',
+                 'cust_main.ship_locationnum',
                  FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
                );
 
index 6316f23..143f62e 100644 (file)
@@ -4,7 +4,7 @@ use strict;
 use vars qw( @ISA @EXPORT_OK $conf
              @cust_main_county %cust_main_county $countyflag ); # $cityflag );
 use Exporter;
-use FS::Record qw( qsearch dbh );
+use FS::Record qw( qsearch qsearchs dbh );
 use FS::cust_bill_pkg;
 use FS::cust_bill;
 use FS::cust_pkg;
@@ -164,6 +164,57 @@ sub recurtax {
   return '';
 }
 
+=item label OPTIONS
+
+Returns a label looking like "Anytown, Alameda County, CA, US".
+
+If the taxname field is set, it will look like
+"CA Sales Tax (Anytown, Alameda County, CA, US)".
+
+If the taxclass is set, then it will be
+"Anytown, Alameda County, CA, US (International)".
+
+Currently it will not contain the district, even if the city+county+state
+is not unique.
+
+OPTIONS may contain "no_taxclass" (hides taxclass) and/or "no_city"
+(hides city).  It may also contain "out", in which case, if this 
+region (district+city+county+state+country) contains no non-zero 
+taxes, the label will read "Out of taxable region(s)".
+
+=cut
+
+sub label {
+  my ($self, %opt) = @_;
+  if ( $opt{'out'} 
+       and $self->tax == 0
+       and !defined(qsearchs('cust_main_county', {
+           'district' => $self->district,
+           'city'     => $self->city,
+           'county'   => $self->county,
+           'state'    => $self->state,
+           'country'  => $self->country,
+           'tax'  => { op => '>', value => 0 },
+        })) )
+  {
+    return 'Out of taxable region(s)';
+  }
+  my $label = $self->country;
+  $label = $self->state.", $label" if $self->state;
+  $label = $self->county." County, $label" if $self->county;
+  if (!$opt{no_city}) {
+    $label = $self->city.", $label" if $self->city;
+  }
+  # ugly labels when taxclass and taxname are both non-null...
+  # but this is how the tax report does it
+  if (!$opt{no_taxclass}) {
+    $label = "$label (".$self->taxclass.')' if $self->taxclass;
+  }
+  $label = $self->taxname." ($label)" if $self->taxname;
+
+  $label;
+}
+
 =item sql_taxclass_sameregion
 
 Returns an SQL WHERE fragment or the empty string to search for entries
@@ -207,21 +258,30 @@ sub _list_sql {
 
 =item taxline TAXABLES_ARRAYREF, [ OPTION => VALUE ... ]
 
-Returns a listref of a name and an amount of tax calculated for the list of
-packages or amounts referenced by TAXABLES_ARRAYREF.  Returns a scalar error
-message on error.  
+Returns an hashref of a name and an amount of tax calculated for the 
+line items (L<FS::cust_bill_pkg> objects) in TAXABLES_ARRAYREF.  The line 
+items must come from the same invoice.  Returns a scalar error message 
+on error.
+
+In addition to calculating the tax for the line items, this will calculate
+any appropriate tax exemptions and attach them to the line items.
 
-Options include custnum and invoice_date and are hints to this method
+Options may include 'custnum' and 'invoice_date' in case the cust_bill_pkg
+objects belong to an invoice that hasn't been inserted yet.
+
+Options may include 'exemptions', an arrayref of L<FS::cust_tax_exempt_pkg>
+objects belonging to the same customer, to be counted against the monthly 
+tax exemption limit if there is one.
 
 =cut
 
+# XXX this should just return a cust_bill_pkg object for the tax,
+# but that requires changing stuff in tax_rate.pm also.
+
 sub taxline {
   my( $self, $taxables, %opt ) = @_;
+  return 'taxline called with no line items' unless @$taxables;
 
-  my @exemptions = ();
-  push @exemptions, @{ $_->_cust_tax_exempt_pkg }
-    for grep { ref($_) } @$taxables;
-    
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
@@ -236,29 +296,92 @@ sub taxline {
   my $name = $self->taxname || 'Tax';
   my $amount = 0;
 
+  my $cust_bill = $taxables->[0]->cust_bill;
+  my $custnum   = $cust_bill ? $cust_bill->custnum : $opt{'custnum'};
+  my $invoice_date = $cust_bill ? $cust_bill->_date : $opt{'invoice_date'};
+  my $cust_main = FS::cust_main->by_key($custnum) if $custnum > 0;
+  if (!$cust_main) {
+    # better way to handle this?  should we just assume that it's taxable?
+    die "unable to calculate taxes for an unknown customer\n";
+  }
+
+  # set a flag if the customer is tax-exempt
+  my $exempt_cust;
+  my $conf = FS::Conf->new;
+  if ( $conf->exists('cust_class-tax_exempt') ) {
+    my $cust_class = $cust_main->cust_class;
+    $exempt_cust = $cust_class->tax if $cust_class;
+  } else {
+    $exempt_cust = $cust_main->tax;
+  }
+
+  # set a flag if the customer is exempt from this tax here
+  my $exempt_cust_taxname = $cust_main->tax_exemption($self->taxname)
+    if $self->taxname;
+
+  # Gather any exemptions that are already attached to these cust_bill_pkgs
+  # so that we can deduct them from the customer's monthly limit.
+  my @existing_exemptions = @{ $opt{'exemptions'} };
+  push @existing_exemptions, @{ $_->cust_tax_exempt_pkg }
+    for @$taxables;
+
   foreach my $cust_bill_pkg (@$taxables) {
 
     my $cust_pkg  = $cust_bill_pkg->cust_pkg;
-    my $cust_bill = $cust_pkg->cust_bill if $cust_pkg;
-    my $custnum   = $cust_pkg ? $cust_pkg->custnum : $opt{custnum};
     my $part_pkg  = $cust_bill_pkg->part_pkg;
-    my $invoice_date = $cust_bill ? $cust_bill->_date : $opt{invoice_date};
-  
-    my $taxable_charged = 0;
-    $taxable_charged += $cust_bill_pkg->setup
-      unless $part_pkg->setuptax =~ /^Y$/i
-          || $self->setuptax =~ /^Y$/i;
-    $taxable_charged += $cust_bill_pkg->recur
-      unless $part_pkg->recurtax =~ /^Y$/i
-          || $self->recurtax =~ /^Y$/i;
-
-    next unless $taxable_charged;
+
+    my @new_exemptions;
+    my $taxable_charged = $cust_bill_pkg->setup + $cust_bill_pkg->recur
+      or next; # don't create zero-amount exemptions
+
+    # XXX the following procedure should probably be in cust_bill_pkg
+
+    if ( $exempt_cust ) {
+
+      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+          amount => $taxable_charged,
+          exempt_cust => 'Y',
+        });
+      $taxable_charged = 0;
+
+    } elsif ( $exempt_cust_taxname ) {
+
+      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+          amount => $taxable_charged,
+          exempt_cust_taxname => 'Y',
+        });
+      $taxable_charged = 0;
+
+    }
+
+    if ( ($part_pkg->setuptax eq 'Y' or $self->setuptax eq 'Y')
+        and $cust_bill_pkg->setup > 0 and $taxable_charged > 0 ) {
+
+      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+          amount => $cust_bill_pkg->setup,
+          exempt_setup => 'Y'
+      });
+      $taxable_charged -= $cust_bill_pkg->setup;
+
+    }
+    if ( ($part_pkg->recurtax eq 'Y' or $self->recurtax eq 'Y')
+        and $cust_bill_pkg->recur > 0 and $taxable_charged > 0 ) {
+
+      push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+          amount => $cust_bill_pkg->recur,
+          exempt_recur => 'Y'
+      });
+      $taxable_charged -= $cust_bill_pkg->recur;
+    
+    }
   
-    if ( $self->exempt_amount && $self->exempt_amount > 0 ) {
+    if ( $self->exempt_amount && $self->exempt_amount > 0 
+      and $taxable_charged > 0 ) {
       #my ($mon,$year) = (localtime($cust_bill_pkg->sdate) )[4,5];
       my ($mon,$year) =
         (localtime( $cust_bill_pkg->sdate || $invoice_date ) )[4,5];
       $mon++;
+      $year += 1900;
       my $freq = $cust_bill_pkg->freq;
       unless ($freq) {
         $freq = $part_pkg->freq || 1;  # less trustworthy fallback
@@ -294,6 +417,7 @@ sub taxline {
               AND taxnum  = ?
               AND year    = ?
               AND month   = ?
+              AND exempt_monthly = 'Y'
         ";
         my $sth = dbh->prepare($sql) or do {
           $dbh->rollback if $oldAutoCommit;
@@ -302,7 +426,7 @@ sub taxline {
         $sth->execute(
           $custnum,
           $self->taxnum,
-          1900+$year,
+          $year,
           $mon,
         ) or do {
           $dbh->rollback if $oldAutoCommit;
@@ -311,9 +435,10 @@ sub taxline {
         my $existing_exemption = $sth->fetchrow_arrayref->[0] || 0;
 
         foreach ( grep { $_->taxnum == $self->taxnum &&
+                         $_->exempt_monthly eq 'Y'   &&
                          $_->month  == $mon          &&
-                         $_->year   == 1900+$year
-                       } @exemptions
+                         $_->year   == $year 
+                       } @existing_exemptions
                 )
         {
           $existing_exemption += $_->amount;
@@ -325,42 +450,50 @@ sub taxline {
           my $addl = $remaining_exemption > $taxable_per_month
             ? $taxable_per_month
             : $remaining_exemption;
+          push @new_exemptions, FS::cust_tax_exempt_pkg->new({
+              amount          => sprintf('%.2f', $addl),
+              exempt_monthly  => 'Y',
+              year            => $year,
+              month           => $mon,
+            });
           $taxable_charged -= $addl;
-
-          my $cust_tax_exempt_pkg = new FS::cust_tax_exempt_pkg ( {
-            'taxnum'     => $self->taxnum,
-            'year'       => 1900+$year,
-            'month'      => $mon,
-            'amount'     => sprintf('%.2f', $addl ),
-          } );
-          if ($cust_bill_pkg->billpkgnum) {
-            $cust_tax_exempt_pkg->billpkgnum($cust_bill_pkg->billpkgnum);
-            my $error = $cust_tax_exempt_pkg->insert;
-            if ( $error ) {
-              $dbh->rollback if $oldAutoCommit;
-              return "fatal: can't insert cust_tax_exempt_pkg: $error";
-            }
-          }else{
-            push @exemptions, $cust_tax_exempt_pkg;
-            push @{ $cust_bill_pkg->_cust_tax_exempt_pkg }, $cust_tax_exempt_pkg;
-          } # if $cust_bill_pkg->billpkgnum
-        } # if $remaining_exemption > 0
-
-        #++
+        }
+        last if $taxable_charged < 0.005;
+        # if they're using multiple months of exemption for a multi-month
+        # package, then record the exemptions in separate months
         $mon++;
-        #until ( $mon < 12 ) { $mon -= 12; $year++; }
-        until ( $mon < 13 ) { $mon -= 12; $year++; }
+        if ( $mon > 12 ) {
+          $mon -= 12;
+          $year++;
+        }
 
       } #foreach $which_month
+    } # if exempt_amount
+
+    $_->taxnum($self->taxnum) foreach @new_exemptions;
+
+    if ( $cust_bill_pkg->billpkgnum ) {
+      die "tried to calculate tax exemptions on a previously billed line item\n";
+      # this is unnecessary
+#      foreach my $cust_tax_exempt_pkg (@new_exemptions) {
+#        my $error = $cust_tax_exempt_pkg->insert;
+#        if ( $error ) {
+#          $dbh->rollback if $oldAutoCommit;
+#          return "can't insert cust_tax_exempt_pkg: $error";
+#        }
+#      }
+    }
 
-    } #if $tax->exempt_amount
+    # attach them to the line item
+    push @{ $cust_bill_pkg->cust_tax_exempt_pkg }, @new_exemptions;
+    push @existing_exemptions, @new_exemptions;
 
+    # If we were smart, we'd also generate a cust_bill_pkg_tax_location 
+    # record at this point, but that would require redesigning more stuff.
     $taxable_charged = sprintf( "%.2f", $taxable_charged);
 
-    $amount += $taxable_charged * $self->tax / 100
-  }
-
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+    $amount += $taxable_charged * $self->tax / 100;
+  } #foreach $cust_bill_pkg
 
   return {
     'name'   => $name,
index c117386..d28997c 100644 (file)
@@ -662,7 +662,7 @@ sub send_receipt {
 
     }
 
-  } else { #not manual
+  } elsif ( ! $cust_main->invoice_noemail ) { #not manual
 
     my $queue = new FS::queue {
        'paynum' => $self->paynum,
index aed99e5..16adea3 100644 (file)
@@ -338,6 +338,9 @@ sub insert {
 
   if ( $conf->config('ticket_system') && $options{ticket_subject} ) {
 
+    #this init stuff is still inefficient, but at least its limited to 
+    # the small number (any?) folks using ticket emailing on pkg order
+
     #eval '
     #  use lib ( "/opt/rt3/local/lib", "/opt/rt3/lib" );
     #  use RT;
@@ -1319,7 +1322,8 @@ sub credit_remaining {
 
 Unsuspends all services (see L<FS::cust_svc> and L<FS::part_svc>) in this
 package, then unsuspends the package itself (clears the susp field and the
-adjourn field if it is in the past).
+adjourn field if it is in the past).  If the suspend reason includes an 
+unsuspension package, that package will be ordered.
 
 Available options are:
 
@@ -1423,6 +1427,9 @@ sub unsuspend {
 
   }
 
+  my $cust_pkg_reason = $self->last_cust_pkg_reason('susp');
+  my $reason = $cust_pkg_reason ? $cust_pkg_reason->reason : '';
+
   my %hash = $self->hash;
   my $inactive = time - $hash{'susp'};
 
@@ -1449,6 +1456,33 @@ sub unsuspend {
     return $error;
   }
 
+  my $unsusp_pkg;
+
+  if ( $reason && $reason->unsuspend_pkgpart ) {
+    my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart)
+      or $error = "Unsuspend package definition ".$reason->unsuspend_pkgpart.
+                  " not found.";
+    my $start_date = $self->cust_main->next_bill_date 
+      if $reason->unsuspend_hold;
+
+    if ( $part_pkg ) {
+      $unsusp_pkg = FS::cust_pkg->new({
+          'custnum'     => $self->custnum,
+          'pkgpart'     => $reason->unsuspend_pkgpart,
+          'start_date'  => $start_date,
+          'locationnum' => $self->locationnum,
+          # discount? probably not...
+      });
+      
+      $error ||= $self->cust_main->order_pkg( 'cust_pkg' => $unsusp_pkg );
+    }
+
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   if ( $conf->config('unsuspend_email_admin') ) {
  
     my $error = send_email(
@@ -1462,6 +1496,11 @@ sub unsuspend {
         'Customer: #'. $self->custnum. ' '. $self->cust_main->name. "\n",
         'Package : #'. $self->pkgnum. " (". $self->part_pkg->pkg_comment. ")\n",
         ( map { "Service : $_\n" } @labels ),
+        ($unsusp_pkg ?
+          "An unsuspension fee was charged: ".
+            $unsusp_pkg->part_pkg->pkg_comment."\n"
+          : ''
+        ),
       ],
     );
 
@@ -3279,7 +3318,12 @@ specifies the user for agent virtualization
 
 =item fcc_line
 
- boolean selects packages containing fcc form 477 telco lines
+boolean; if true, returns only packages with more than 0 FCC phone lines.
+
+=item state, country
+
+Limit to packages with a service location in the specified state and country.
+For FCC 477 reporting, mostly.
 
 =back
 
@@ -3453,8 +3497,8 @@ sub search {
 
   if ( exists($params->{'censustract'}) ) {
     $params->{'censustract'} =~ /^([.\d]*)$/;
-    my $censustract = "cust_main.censustract = '$1'";
-    $censustract .= ' OR cust_main.censustract is NULL' unless $1;
+    my $censustract = "cust_location.censustract = '$1'";
+    $censustract .= ' OR cust_location.censustract is NULL' unless $1;
     push @where,  "( $censustract )";
   }
 
@@ -3466,10 +3510,22 @@ sub search {
      )
   {
     if ($1) {
-      push @where, "cust_main.censustract LIKE '$1%'";
+      push @where, "cust_location.censustract LIKE '$1%'";
     } else {
       push @where,
-        "( cust_main.censustract = '' OR cust_main.censustract IS NULL )";
+        "( cust_location.censustract = '' OR cust_location.censustract IS NULL )";
+    }
+  }
+
+  ###
+  # parse country/state
+  ###
+  for (qw(state country)) { # parsing rules are the same for these
+  if ( exists($params->{$_}) 
+    && uc($params->{$_}) =~ /^([A-Z]{2})$/ )
+    {
+      # XXX post-2.3 only--before that, state/country may be in cust_main
+      push @where, "cust_location.$_ = '$1'";
     }
   }
 
@@ -3597,7 +3653,8 @@ sub search {
 
   my $addl_from = 'LEFT JOIN cust_main USING ( custnum  ) '.
                   'LEFT JOIN part_pkg  USING ( pkgpart  ) '.
-                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) ';
+                  'LEFT JOIN pkg_class ON ( part_pkg.classnum = pkg_class.classnum ) '.
+                  'LEFT JOIN cust_location USING ( locationnum ) ';
 
   my $select;
   my $count_query;
@@ -3606,13 +3663,6 @@ sub search {
 
     $select = "DISTINCT substr($zip,1,5) as zip";
     $orderby = "ORDER BY substr($zip,1,5)";
-    $addl_from .= 'LEFT JOIN cust_location ON (
-                     cust_location.locationnum = COALESCE(
-                                                   cust_pkg.locationnum,
-                                                   cust_main.ship_locationnum,
-                                                   cust_main.bill_locationnum
-                                                 )
-                                              )';
     $count_query = "SELECT COUNT( DISTINCT substr($zip,1,5) )";
   } else {
     $select = join(', ',
index a207940..5f4d0dc 100644 (file)
@@ -106,7 +106,8 @@ sub insert {
       'amount'   => $self->amount,
       'percent'  => $self->percent,
       'months'   => $self->months,
-      'setup'   => $self->setup,
+      'setup'    => $self->setup,
+      #'linked'   => $self->linked,
       'disabled' => 'Y',
     };
     my $error = $discount->insert;
index 2ec8f12..5206931 100644 (file)
@@ -335,10 +335,10 @@ sub check {
     ($part_svc) = grep { $_->svcpart == $self->svcpart } $cust_pkg->part_svc;
     return "No svcpart ". $self->svcpart.
            " services in pkgpart ". $cust_pkg->pkgpart
-      unless $part_svc;
+      unless $part_svc || $ignore_quantity;
     return "Already ". $part_svc->get('num_cust_svc'). " ". $part_svc->svc.
            " services for pkgnum ". $self->pkgnum
-      if $part_svc->get('num_avail') == 0 and !$ignore_quantity;
+      if !$ignore_quantity && $part_svc->get('num_avail') <= 0 ;
   }
 
   $self->SUPER::check;
index e63b84b..bbabb5b 100644 (file)
@@ -7,6 +7,10 @@ use FS::cust_main_Mixin;
 use FS::cust_bill_pkg;
 use FS::cust_main_county;
 use FS::cust_credit_bill_pkg;
+use FS::UID qw(dbh);
+use FS::upgrade_journal;
+
+# some kind of common ancestor with cust_bill_pkg_tax_location would make sense
 
 @ISA = qw( FS::cust_main_Mixin FS::Record );
 
@@ -32,22 +36,45 @@ FS::cust_tax_exempt_pkg - Object methods for cust_tax_exempt_pkg records
 =head1 DESCRIPTION
 
 An FS::cust_tax_exempt_pkg object represents a record of a customer tax
-exemption.  Currently this is only used for "texas tax".  FS::cust_tax_exempt
-inherits from FS::Record.  The following fields are currently supported:
+exemption.  Whenever a package would be taxed (based on its location and
+taxclass), but some or all of it is exempt from taxation, an 
+FS::cust_tax_exempt_pkg record is created.
+
+FS::cust_tax_exempt inherits from FS::Record.  The following fields are 
+currently supported:
 
 =over 4
 
 =item exemptpkgnum - primary key
 
-=item billpkgnum - invoice line item (see L<FS::cust_bill_pkg>)
+=item billpkgnum - invoice line item (see L<FS::cust_bill_pkg>) that 
+was exempted from tax.
 
 =item taxnum - tax rate (see L<FS::cust_main_county>)
 
-=item year
+=item year - the year in which the exemption occurred.  NULL if this 
+is a customer or package exemption rather than a monthly exemption.
+
+=item month - the month in which the exemption occurred.  NULL if this
+is a customer or package exemption.
+
+=item amount - the amount of revenue exempted.  For monthly exemptions
+this may be anything up to the monthly exemption limit defined in 
+L<FS::cust_main_county> for this tax.  For customer exemptions it is 
+always the full price of the line item.  For package exemptions it 
+may be the setup fee, the recurring fee, or the sum of those.
+
+=item exempt_cust - flag indicating that the customer is tax-exempt
+(cust_main.tax = 'Y').
 
-=item month
+=item exempt_cust_taxname - flag indicating that the customer is exempt 
+from the tax with this name (see L<FS::cust_main_exemption).
 
-=item amount
+=item exempt_setup, exempt_recur: flag indicating that the package's setup
+or recurring fee is not taxable (part_pkg.setuptax and part_pkg.recurtax).
+
+=item exempt_monthly: flag indicating that this is a monthly per-customer
+exemption (Texas tax).
 
 =back
 
@@ -109,18 +136,44 @@ and replace methods.
 sub check {
   my $self = shift;
 
-  $self->ut_numbern('exemptnum')
-#    || $self->ut_foreign_key('custnum', 'cust_main', 'custnum')
+  my $error = $self->ut_numbern('exemptnum')
     || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg', 'billpkgnum')
     || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum')
     || $self->ut_foreign_keyn('creditbillpkgnum',
                               'cust_credit_bill_pkg',
                               'creditbillpkgnum')
-    || $self->ut_number('year') #check better
-    || $self->ut_number('month') #check better
+    || $self->ut_numbern('year') #check better
+    || $self->ut_numbern('month') #check better
     || $self->ut_money('amount')
+    || $self->ut_flag('exempt_cust')
+    || $self->ut_flag('exempt_setup')
+    || $self->ut_flag('exempt_recur')
+    || $self->ut_flag('exempt_cust_taxname')
     || $self->SUPER::check
   ;
+
+  return $error if $error;
+
+  if ( $self->get('exempt_cust') ) {
+    $self->set($_ => '') for qw(
+      exempt_cust_taxname exempt_setup exempt_recur exempt_monthly month year
+    );
+  } elsif ( $self->get('exempt_cust_taxname')  ) {
+    $self->set($_ => '') for qw(
+      exempt_setup exempt_recur exempt_monthly month year
+    );
+  } elsif ( $self->get('exempt_setup') || $self->get('exempt_recur') ) {
+    $self->set($_ => '') for qw(exempt_monthly month year);
+  } elsif ( $self->get('exempt_monthly') ) {
+    $self->year =~ /^\d{4}$/
+        or return "illegal exemption year: '".$self->year."'";
+    $self->month >= 1 && $self->month <= 12
+        or return "illegal exemption month: '".$self->month."'";
+  } else {
+    return "no exemption type selected";
+  }
+
+  '';
 }
 
 =item cust_main_county
@@ -135,6 +188,18 @@ sub cust_main_county {
   qsearchs( 'cust_main_county', { 'taxnum', $self->taxnum } );
 }
 
+sub _upgrade_data {
+  my $class = shift;
+
+  my $journal = 'cust_tax_exempt_pkg_flags';
+  if ( !FS::upgrade_journal->is_done($journal) ) {
+    my $sql = "UPDATE cust_tax_exempt_pkg SET exempt_monthly = 'Y' ".
+              "WHERE month IS NOT NULL";
+    dbh->do($sql) or die dbh->errstr;
+    FS::upgrade_journal->set_done($journal);
+  }
+}
+
 =back
 
 =head1 BUGS
diff --git a/FS/FS/cust_tax_exempt_pkg_void.pm b/FS/FS/cust_tax_exempt_pkg_void.pm
new file mode 100644 (file)
index 0000000..bfbc8c7
--- /dev/null
@@ -0,0 +1,143 @@
+package FS::cust_tax_exempt_pkg_void;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record; # qw( qsearch qsearchs );
+use FS::cust_bill_pkg_void;
+use FS::cust_main_county;
+
+=head1 NAME
+
+FS::cust_tax_exempt_pkg_void - Object methods for cust_tax_exempt_pkg_void records
+
+=head1 SYNOPSIS
+
+  use FS::cust_tax_exempt_pkg_void;
+
+  $record = new FS::cust_tax_exempt_pkg_void \%hash;
+  $record = new FS::cust_tax_exempt_pkg_void { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::cust_tax_exempt_pkg_void object represents a voided record of a customer
+tax exemption.  FS::cust_tax_exempt_pkg_void inherits from FS::Record.  The
+following fields are currently supported:
+
+=over 4
+
+=item exemptpkgnum
+
+primary key
+
+=item billpkgnum
+
+billpkgnum
+
+=item taxnum
+
+taxnum
+
+=item year
+
+year
+
+=item month
+
+month
+
+=item creditbillpkgnum
+
+creditbillpkgnum
+
+=item amount
+
+amount
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'cust_tax_exempt_pkg_void'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=cut
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=cut
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_number('exemptpkgnum')
+    || $self->ut_foreign_key('billpkgnum', 'cust_bill_pkg_void', 'billpkgnum' )
+    || $self->ut_foreign_key('taxnum', 'cust_main_county', 'taxnum')
+    || $self->ut_numbern('year')
+    || $self->ut_numbern('month')
+    || $self->ut_numbern('creditbillpkgnum') #no FK check, will have been del'ed
+    || $self->ut_money('amount')
+    || $self->ut_flag('exempt_cust')
+    || $self->ut_flag('exempt_setup')
+    || $self->ut_flag('exempt_recur')
+    || $self->ut_flag('exempt_cust_taxname')
+    || $self->ut_flag('exempt_monthly')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
index 161a654..1a9bf5a 100644 (file)
@@ -298,7 +298,7 @@ sub batch_import {
     }
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
-      return "Unexpected trailing columns in line (wrong format?): $line";
+      return "Unexpected trailing columns in line (wrong format?) importing cust_tax_location: $line";
     }
 
     my $error = &{$hook}(\%cust_tax_location);
index 0459041..cd7bbe3 100644 (file)
@@ -25,7 +25,10 @@ sub append {
   my $self = shift;
   my $prefixes = ($self->{prefixes} ||= {});
   foreach my $cdr (@_) {
-    my $phonenum = $self->{inbound} ? $cdr->src : $cdr->dst;
+    my (undef, $phonenum) = $cdr->parse_number(
+      column => ( $self->{inbound} ? 'src' : 'dst' ),
+    );
+
     $phonenum =~ /^(\d{$prefix_length})/;
     my $prefix = $1 || 'other';
     warn "$me appending ".$cdr->dst." to $prefix\n" if $DEBUG;
index 88cbdd4..f6f9945 100644 (file)
@@ -136,6 +136,7 @@ sub check {
     || $self->ut_floatn('months') #actually decimal, but this will do
     || $self->ut_enum('disabled', [ '', 'Y' ])
     || $self->ut_enum('setup', [ '', 'Y' ])
+    #|| $self->ut_enum('linked', [ '', 'Y' ])
   ;
   return $error if $error;
 
diff --git a/FS/FS/h_cust_main_exemption.pm b/FS/FS/h_cust_main_exemption.pm
new file mode 100644 (file)
index 0000000..072c412
--- /dev/null
@@ -0,0 +1,19 @@
+package FS::h_cust_main_exemption;
+
+use strict;
+use base qw( FS::h_Common FS::cust_main_exemption );
+
+sub table { 'h_cust_main_exemption' };
+
+=head1 NAME
+
+FS::h_cust_main_exemption - Historical customer tax exemption records.
+
+=head1 SEE ALSO
+
+L<FS::cust_main_exemption>,  L<FS::h_Common>, L<FS::Record>.
+
+=cut
+
+1;
+
diff --git a/FS/FS/h_part_pkg.pm b/FS/FS/h_part_pkg.pm
new file mode 100644 (file)
index 0000000..2c0e65f
--- /dev/null
@@ -0,0 +1,37 @@
+package FS::h_part_pkg;
+
+use strict;
+use vars qw( @ISA );
+use base qw(FS::h_Common FS::part_pkg);
+
+sub table { 'h_part_pkg' };
+
+sub _rebless {}; # don't try to rebless these
+
+=head1 NAME
+
+FS::h_part_pkg - Historical record of package definition.
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+An FS::h_part_pkg object represents historical changes to package
+definitions.
+
+=head1 BUGS
+
+Many important properties of a part_pkg are in other tables, especially
+plan options, service allotments, and link/bundle relationships.  The 
+methods to access those from the part_pkg will work, but they're 
+really accessing current, not historical, data.  Be careful.
+
+=head1 SEE ALSO
+
+L<FS::part_pkg>,  L<FS::h_Common>, L<FS::Record>, schema.html from the base
+documentation.
+
+=cut
+
+1;
+
index 62f16fa..b7371c9 100644 (file)
@@ -306,8 +306,8 @@ sub targets {
   });
   my @tested_objects;
   foreach my $object ( @objects ) {
-    my $cust_event = $self->new_cust_event($object);
-    next unless $cust_event->test_conditions('time' => $time);
+    my $cust_event = $self->new_cust_event($object, 'time' => $time);
+    next unless $cust_event->test_conditions;
 
     $object->set('cust_event', $cust_event);
     push @tested_objects, $object;
diff --git a/FS/FS/part_event/Action/Mixin/credit_agent_pkg_class.pm b/FS/FS/part_event/Action/Mixin/credit_agent_pkg_class.pm
new file mode 100644 (file)
index 0000000..73d32e0
--- /dev/null
@@ -0,0 +1,25 @@
+package FS::part_event::Action::Mixin::credit_agent_pkg_class;
+use base qw( FS::part_event::Action::Mixin::credit_pkg );
+
+use strict;
+
+sub option_fields {
+  my $class = shift;
+  my %option_fields = $class->SUPER::option_fields;
+  delete $option_fields{'percent'};
+  %option_fields;
+}
+
+sub _calc_credit_percent {
+  my( $self, $cust_pkg ) = @_;
+
+  my $agent_pkg_class = qsearchs( 'agent_pkg_class', {
+    'agentnum' => $self->cust_main($cust_pkg)->agentnum,
+    'classnum' => $cust_pkg->classnum,
+  });
+
+  $agent_pkg_class ? $agent_pkg_class->commission_percent : 0;
+
+}
+
+1;
index aeda92f..9dcd701 100644 (file)
@@ -51,7 +51,7 @@ sub _calc_credit {
     }
   }
 
-  my $percent = $self->option('percent');
+  my $percent = $self->_calc_credit_percent($cust_pkg);
 
   #my @arg = $no_cust_pkg{$what} ? () : ($cust_pkg);
   my @arg = ($what eq 'setup_cost') ? () : ($cust_pkg);
@@ -60,4 +60,9 @@ sub _calc_credit {
 
 }
 
+sub _calc_credit_percent {
+  my( $self, $cust_pkg ) = @_;
+  $self->option('percent');
+}
+
 1;
index 4bcee98..e1c77be 100644 (file)
@@ -18,7 +18,7 @@ sub do_action {
   my $agent_cust_main = $agent->agent_cust_main;
     #? or return "No customer record for agent ". $agent->agent;
 
-  my $amount    = $self->_calc_credit($cust_pkg);
+  my $amount = $self->_calc_credit($cust_pkg);
   return '' unless $amount > 0;
 
   my $reasonnum = $self->option('reasonnum');
@@ -29,6 +29,7 @@ sub do_action {
     'eventnum' => $cust_event->eventnum,
     'addlinfo' => 'for customer #'. $cust_main->display_custnum.
                                ': '.$cust_main->name,
+    #'commission_agentnum' => $agent->agentnum,
   );
   die "Error crediting customer ". $agent_cust_main->custnum.
       " for agent commission: $error"
diff --git a/FS/FS/part_event/Action/pkg_agent_credit_pkg_class.pm b/FS/FS/part_event/Action/pkg_agent_credit_pkg_class.pm
new file mode 100644 (file)
index 0000000..3dcf668
--- /dev/null
@@ -0,0 +1,9 @@
+package FS::part_event::Action::pkg_agent_credit_pkg_class;
+
+use strict;
+use base qw( FS::part_event::Action::Mixin::credit_agent_pkg_class
+             FS::part_event::Action::pkg_agent_credit );
+
+sub description { 'Credit the agent an amount based on their commission percentage for the referred package class'; }
+
+1;
diff --git a/FS/FS/part_event/Condition/after_event.pm b/FS/FS/part_event/Condition/after_event.pm
new file mode 100644 (file)
index 0000000..1d8d212
--- /dev/null
@@ -0,0 +1,81 @@
+package FS::part_event::Condition::after_event;
+
+use strict;
+use FS::Record qw( qsearchs );
+use FS::part_event;
+use FS::cust_event;
+
+use base qw( FS::part_event::Condition );
+
+sub description { "After running another event" }
+
+# Runs the event at least X days after the most recent time another event
+# ran on the same object.
+
+sub option_fields {
+  (
+    'eventpart' => { label=>'Event', type=>'select-part_event',
+                     disable_empty => 1,
+                     hashref => { disabled => '' },
+                   },
+    'run_delay' => { label=>'Delay', type=>'freq', value=>'1',  },
+  );
+}
+
+# Specification:
+# Given an event B that has this condition, where the "eventpart"
+# option is set to event A, and the "run_delay" option is set to 
+# X days.
+# This condition is TRUE if:
+# - Event A last ran X or more days in the past,
+# AND
+# - Event B has not run since the most recent occurrence of event A.
+
+sub condition {
+  # similar to "once_every", but with a different eventpart
+  my($self, $object, %opt) = @_;
+
+  my $obj_pkey = $object->primary_key;
+  my $tablenum = $object->$obj_pkey();
+
+  my $before = $self->option_age_from('run_delay',$opt{'time'});
+  my $eventpart = $self->option('eventpart');
+
+  my %hash = (
+    'eventpart' => $eventpart,
+    'tablenum'  => $tablenum,
+    'status'    => { op => '!=', value => 'failed' },
+  );
+
+  my $most_recent_other = qsearchs( {
+    'table'     => 'cust_event',
+    'hashref'   => \%hash,
+    'order_by'  => " ORDER BY _date DESC LIMIT 1",
+  } )
+    or return 0; # if it hasn't run at all, return false
+  
+  return 0 if $most_recent_other->_date > $before; # we're still in the delay
+
+  # now see if there's been an instance of this event since the one we're
+  # following...
+  $hash{'eventpart'} = $self->eventpart;
+  if ( $opt{'cust_event'} and $opt{'cust_event'}->eventnum =~ /^(\d+)$/ ) {
+    $hash{'eventnum'} = { op => '!=', value => $1 };
+  }
+
+  my $most_recent_self = qsearchs( {
+    'table'     => 'cust_event',
+    'hashref'   => \%hash,
+    'order_by'  => " ORDER BY _date DESC LIMIT 1",
+  } );
+
+  return 0 if defined($most_recent_self) 
+          and $most_recent_self->_date >= $most_recent_other->_date;
+  # the follower has already run
+  
+  1;
+}
+
+# condition_sql, maybe someday
+
+1;
index 45773e0..b0f708a 100644 (file)
@@ -4,10 +4,12 @@ use strict;
 use vars qw( @ISA @EXPORT_OK $DEBUG %exports );
 use Exporter;
 use Tie::IxHash;
-use base qw( FS::option_Common FS::m2m_Common ); # m2m for 'export_nas'
+use base qw( FS::option_Common FS::m2m_Common );
 use FS::Record qw( qsearch qsearchs dbh );
 use FS::part_svc;
 use FS::part_export_option;
+use FS::part_export_machine;
+use FS::svc_export_machine;
 use FS::export_svc;
 
 #for export modules, though they should probably just use it themselves
@@ -108,6 +110,50 @@ otherwise returns false.
 If a hash reference of options is supplied, part_export_option records are
 created (see L<FS::part_export_option>).
 
+=cut
+
+sub insert {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $error = $self->SUPER::insert(@_);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  #kinda false laziness with process_m2name
+  my @machines = map { $_ =~ s/^\s+//; $_ =~ s/\s+$//; $_ }
+                   grep /\S/,
+                     split /[\n\r]{1,2}/,
+                       $self->part_export_machine_textarea;
+
+  foreach my $machine ( @machines ) {
+
+    my $part_export_machine = new FS::part_export_machine {
+      'exportnum' => $self->exportnum,
+      'machine'   => $machine,
+    };
+    $error = $part_export_machine->insert;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+}
+
 =item delete
 
 Delete this record from the database.
@@ -117,13 +163,13 @@ Delete this record from the database.
 #foreign keys would make this much less tedious... grr dumb mysql
 sub delete {
   my $self = shift;
+
   local $SIG{HUP} = 'IGNORE';
   local $SIG{INT} = 'IGNORE';
   local $SIG{QUIT} = 'IGNORE';
   local $SIG{TERM} = 'IGNORE';
   local $SIG{TSTP} = 'IGNORE';
   local $SIG{PIPE} = 'IGNORE';
-
   my $oldAutoCommit = $FS::UID::AutoCommit;
   local $FS::UID::AutoCommit = 0;
   my $dbh = dbh;
@@ -147,10 +193,103 @@ sub delete {
     }
   }
 
-  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  foreach my $part_export_machine ( $self->part_export_machine ) {
+    my $error = $part_export_machine->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
 
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
   '';
+}
+
+=item replace [ OLD_RECORD ] [ HASHREF | OPTION => VALUE ... ]
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+If a list or hash reference of options is supplied, option records are created
+or modified.
+
+=cut
+
+sub replace {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $error = $self->SUPER::replace(@_);
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  if ( $self->part_export_machine_textarea ) {
+
+    my %part_export_machine = map { $_->machine => $_ }
+                                $self->part_export_machine;
+
+    my @machines = map { $_ =~ s/^\s+//; $_ =~ s/\s+$//; $_ }
+                     grep /\S/,
+                       split /[\n\r]{1,2}/,
+                         $self->part_export_machine_textarea;
+
+    foreach my $machine ( @machines ) {
+
+      if ( $part_export_machine{$machine} ) {
+
+        if ( $part_export_machine{$machine}->disabled eq 'Y' ) {
+          $part_export_machine{$machine}->disabled('');
+          $error = $part_export_machine{$machine}->replace;
+          if ( $error ) {
+            $dbh->rollback if $oldAutoCommit;
+            return $error;
+          }
+        }
 
+        delete $part_export_machine{$machine}; #so we don't disable it below
+
+      } else {
+
+        my $part_export_machine = new FS::part_export_machine {
+                                        'exportnum' => $self->exportnum,
+                                        'machine'   => $machine
+                                      };
+        $error = $part_export_machine->insert;
+        if ( $error ) {
+          $dbh->rollback if $oldAutoCommit;
+          return $error;
+        }
+  
+      }
+
+    }
+
+
+    foreach my $part_export_machine ( values %part_export_machine ) {
+      $part_export_machine->disabled('Y');
+      $error = $part_export_machine->replace;
+      if ( $error ) {
+        $dbh->rollback if $oldAutoCommit;
+        return $error;
+      }
+    }
+
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
 }
 
 =item check
@@ -166,7 +305,7 @@ sub check {
   my $error = 
     $self->ut_numbern('exportnum')
     || $self->ut_textn('exportname')
-    || $self->ut_domain('machine')
+    || $self->ut_domainn('machine')
     || $self->ut_alpha('exporttype')
   ;
   return $error if $error;
@@ -192,6 +331,31 @@ sub label {
   ($self->exportname || $self->exporttype ). ' ('. $self->machine. ')';
 }
 
+=item label_html
+
+Returns a label for this export, "exportname: exporttype to machine".
+
+=cut
+
+sub label_html {
+  my $self = shift;
+
+  my $label = $self->exportname
+                ? '<B>'. $self->exportname. '</B>: ' #<BR>'.
+                : '';
+
+  $label .= $self->exporttype;
+
+  $label .= ' to '. ( $self->machine eq '_SVC_MACHINE'
+                        ? 'per-service hostname'
+                        : $self->machine
+                    )
+    if $self->machine;
+
+  $label;
+
+}
+
 #=item part_svc
 #
 #Returns the service definition (see L<FS::part_svc>) for this export.
@@ -233,6 +397,20 @@ sub cust_svc {
       $self->export_svc;
 }
 
+=item part_export_machine
+
+Returns all machines as FS::part_export_machine objects (see
+L<FS::part_export_machine>).
+
+=cut
+
+sub part_export_machine {
+  my $self = shift;
+  map { $_ } #behavior of sort undefined in scalar context
+    sort { $a->machine cmp $b->machine }
+      qsearch('part_export_machine', { 'exportnum' => $self->exportnum } );
+}
+
 =item export_svc
 
 Returns a list of associated FS::export_svc records.
@@ -293,6 +471,26 @@ sub _rebless {
   $self;
 }
 
+=item svc_machine
+
+=cut
+
+sub svc_machine {
+  my( $self, $svc_x ) = @_;
+
+  return $self->machine unless $self->machine eq '_SVC_MACHINE';
+
+  my $svc_export_machine = qsearchs('svc_export_machine', {
+    'svcnum'    => $svc_x->svcnum,
+    'exportnum' => $self->exportnum,
+  })
+    #would only happen if you add this export to existing services without a
+    #machine set then try to run exports without setting it... right?
+    or die "No hostname selected for ".($self->exportname || $self->exporttype);
+
+  return $svc_export_machine->part_export_machine->machine;
+}
+
 #these should probably all go away, just let the subclasses define em
 
 =item export_insert SVC_OBJECT
index afc45db..d153728 100644 (file)
@@ -16,10 +16,12 @@ tie my %options, 'Tie::IxHash',
 # admin logins.
 
 %info = (
-  'svc'       => 'svc_acct',
-  'desc'      => 'Google hosted mail',
-  'options'   => \%options,
-  'nodomain'  => 'Y',
+  'svc'        => 'svc_acct',
+  'desc'       => 'Google hosted mail',
+  'options'    => \%options,
+  'nodomain'   => 'Y',
+  'no_machine' => 1,
+  'default_svc_class' => 'Email',
   'notes'    => <<'END'
 Export accounts to the Google Provisioning API.  Requires 
 REST::Google::Apps::Provisioning from CPAN.
index b4c64ac..23df7b3 100644 (file)
@@ -51,6 +51,7 @@ tie %options, 'Tie::IxHash',
   'svc'     => 'svc_acct',
   'desc'    => 'Send an HTTP or HTTPS GET or POST request, for accounts.',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
 modification and deletion.  For HTTPS support,
index d8d70a3..50b6fae 100644 (file)
@@ -15,9 +15,11 @@ tie my %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'    => 'svc_acct',
-  'desc'   => 'Real-time export to Plesk managed mail service',
-  'options'=> \%options,
+  'svc'        => 'svc_acct',
+  'desc'       => 'Real-time export to Plesk managed mail service',
+  'options'    => \%options,
+  'no_machine' => 1,
+  'default_svc_class' => 'Email',
   'notes'  => <<'END'
 Real-time export to
 <a href="http://www.swsoft.com/">Plesk</a> managed server.
index ffe39ca..8163f20 100644 (file)
@@ -60,11 +60,13 @@ my $postfix_native_mailbox_map =
                  keys %postfix_native_mailbox_map      );
 
 %info = (
-  'svc'      => 'svc_acct',
-  'desc'     => 'Real-time export of accounts to SQL databases '.
-                '(vpopmail, Postfix+Courier IMAP, others?)',
-  'options'  => \%options,
-  'nodomain' => '',
+  'svc'        => 'svc_acct',
+  'desc'       => 'Real-time export of accounts to SQL databases '.
+                  '(vpopmail, Postfix+Courier IMAP, others?)',
+  'options'    => \%options,
+  'nodomain'   => '',
+  'no_machine' => 1,
+  'default_svc_class' => 'Email',
   'notes'    => <<END
 Export accounts (svc_acct records) to SQL databases.  Currently has default
 configurations for vpopmail and Postfix+Courier IMAP but intended to be
index e6aeb20..248105f 100644 (file)
@@ -14,6 +14,7 @@ delete $options{$_} for qw( table schema static primary_key );
   'desc'     => 'Mailbox status information from SQL',
   'options'  => \%options,
   'nodomain' => '',
+  'no_machine' => 1,
   'notes'    => <<END
 Read mailbox status information (vacation and spam settings) from an SQL
 database, tables "vacation" and "users" respectively.
index d746f29..3070f28 100644 (file)
@@ -34,10 +34,11 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_acct',
   'desc'    => 'Configurable provisioning of accounts via the XML-RPC protocol',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END',
 Configurable, real-time export of accounts via the XML-RPC protocol.<BR>
 <BR>
-If using "Individual values" parameter style, specfify one parameter per line.<BR>
+If using "Individual values" parameter style, specify one parameter per line.<BR>
 <BR>
 If using "Struct of name/value pairs" parameter style, specify one name and
 value on each line, separated by whitespace.<BR>
index 0e65ca0..06e2c23 100644 (file)
@@ -20,6 +20,7 @@ tie my %options, 'Tie::IxHash',
   'desc'     =>
     'Export to Amazon EC2',
   'options'  => \%options,
+  'no_machine' => 1,
   'notes'    => <<'END'
 Create instances in the Amazon EC2 (Elastic compute cloud).  Install
 Net::Amazon::EC2 perl module.  Advisable to set svc_external-skip_manual config
index c006db9..e22bbf2 100644 (file)
@@ -37,6 +37,7 @@ tie my %options, 'Tie::IxHash',
     'Real-time export to Artera Turbo Reseller API',
   'options'  => \%options,
   #'nodomain' => 'Y',
+  'no_machine' => 1,
   'notes'    => <<'END'
 Real-time export to <a href="http://www.arteraturbo.com/">Artera Turbo</a>
 Reseller API.  Requires installation of
index 9edfee5..c1ed7fc 100644 (file)
@@ -45,6 +45,7 @@ tie %options, 'Tie::IxHash',
   'svc'     => 'svc_broadband',
   'desc'    => 'Send an HTTP or HTTPS GET or POST request, for accounts.',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 <p>Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
 modification and deletion.  For HTTPS support,
index a160c99..5a8ffac 100644 (file)
@@ -43,6 +43,7 @@ FS::UID->install_callback(
   'svc'     => 'svc_broadband',
   'desc'    => 'Create a NAS entry in Freeside',
   'options' => \%options,
+  'no_machine' => 1,
   'weight'  => 10,
   'notes'   => <<'END'
 <p>Create an entry in the NAS (RADIUS client) table, inheriting the IP 
index cb1740e..44b4dba 100644 (file)
@@ -52,6 +52,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_broadband',
   'desc'    => 'Send SNMP requests to the service IP address',
   'options' => \%options,
+  'no_machine' => 1,
   'weight'  => 10,
   'notes'   => <<'END'
 Send one or more SNMP SET requests to the IP address registered to the service.
index 697d3cd..4f526c8 100644 (file)
@@ -24,6 +24,7 @@ tie my %options, 'Tie::IxHash',
   'desc'     => 'Real-time export of broadband services to SQL databases ',
   'options'  => \%options,
   'nodomain' => '',
+  'no_machine' => 1,
   'notes'    => <<END
 END
 );
index 5806362..b5d1a80 100644 (file)
@@ -55,6 +55,7 @@ tie %options, 'Tie::IxHash',
   'svc'      => 'svc_broadband',
   'desc'     => 'Real-time export to SQL-backed RADIUS (such as FreeRadius) for broadband services',
   'options'  => \%options,
+  'no_machine' => 1,
   'nas'      => 'Y',
   'notes'    => <<END,
 Real-time export of <b>radcheck</b>, <b>radreply</b>, and <b>usergroup</b> 
index a3ec5e0..8b66225 100644 (file)
@@ -36,6 +36,7 @@ tie %options, 'Tie::IxHash',
   'svc'     => [qw( svc_acct svc_domain svc_forward svc_mailinglist )],
   'desc'    => 'Real-time export of accounts, domains, mail forwards and mailing lists to a CommuniGate Pro mail server',
   'options' => \%options,
+  'default_svc_class' => 'Email',
   'notes'   => <<'END'
 Real time export of accounts, domains, mail forwards and mailing lists to a
 <a href="http://www.stalker.com/CommuniGatePro/">CommuniGate Pro</a>
index e25043f..cecea28 100644 (file)
@@ -16,6 +16,7 @@ tie my %options, 'Tie::IxHash', %FS::part_export::communigate_pro::options,
     'Real-time export to a CommuniGate Pro mail server, one domain only',
   'options'  => \%options,
   'nodomain' => 'Y',
+  'default_svc_class' => 'Email',
   'notes'    => <<'END'
 Real time export to a
 <a href="http://www.stalker.com/CommuniGatePro/">CommuniGate Pro</a>
index 96fa437..2ae97e1 100644 (file)
@@ -18,6 +18,7 @@ tie my %options, 'Tie::IxHash',
   'svc'    => 'svc_acct',
   'desc'   => 'Real-time export to Critical Path Account Provisioning Protocol',
   'options'=> \%options,
+  'default_svc_class' => 'Email',
   'notes'  => <<'END'
 Real-time export to
 <a href="http://www.cp.net/">Critial Path Account Provisioning Protocol</a>.
index 0ad00df..6c61e3d 100644 (file)
@@ -190,3 +190,5 @@ sub cpanel_connect {
 
   $whm;
 }
+
+1;
index e8b677b..e834f93 100644 (file)
@@ -55,6 +55,7 @@ tie %options, 'Tie::IxHash',
   'svc'     => 'cust_main',
   'desc'    => 'Send an HTTP or HTTPS GET or POST request, for customers.',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Send an HTTP or HTTPS GET or POST to the specified URL on customer addition,
 modification and deletion.  For HTTPS support,
index 84c9e5a..246d5b3 100644 (file)
@@ -17,6 +17,8 @@ tie my %options, 'Tie::IxHash',
   'desc'     => 'Real-time export to Cyrus IMAP server',
   'options'  => \%options,
   'nodomain' => 'Y',
+  'no_machine' => 1, #de facto... but "server" option should move to it
+  'default_svc_class' => 'Email',
   'notes'    => <<'END'
 Integration with
 <a href="http://asg.web.cmu.edu/cyrus/imapd/">Cyrus IMAP Server</a>.
index 320d0a6..2717233 100644 (file)
@@ -20,6 +20,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_phone',
   'desc'    => 'Provision e911 services via Dash Carrier Services',
   'notes'   => 'Provision e911 services via Dash Carrier Services',
+  'no_machine' => 1,
   'options' => \%options,
 );
 
diff --git a/FS/FS/part_export/dma_radiusmanager.pm b/FS/FS/part_export/dma_radiusmanager.pm
new file mode 100644 (file)
index 0000000..6e56c99
--- /dev/null
@@ -0,0 +1,350 @@
+package FS::part_export::dma_radiusmanager;
+
+use strict;
+use vars qw($DEBUG %info %options);
+use base 'FS::part_export';
+use FS::part_svc;
+use FS::svc_acct;
+use FS::radius_group;
+use Tie::IxHash;
+use Digest::MD5 'md5_hex';
+
+use Locale::Country qw(code2country);
+use Locale::SubCountry;
+use Date::Format 'time2str';
+
+tie %options, 'Tie::IxHash',
+  'dbname'    => { label=>'Database name', default=>'radius' },
+  'username'  => { label=>'Database username' },
+  'password'  => { label=>'Database password' },
+  'manager'   => { label=>'Manager name' },
+  'groupid'   => { label=>'Group ID', default=>'1' },
+  'service_prefix' => { label=>'Service name prefix' },
+  'nasnames'  => { label=>'NAS IDs/addresses' },
+  'debug'     => { label=>'Enable debugging', type=>'checkbox' },
+;
+
+%info = (
+  'svc'       => 'svc_acct',
+  'desc'      => 'Export to DMA Radius Manager',
+  'options'   => \%options,
+  'nodomain'  => 'Y',
+  'notes'     => '', #XXX
+);
+
+$DEBUG = 0;
+
+sub connect {
+  my $self = shift;
+  my $datasrc = 'dbi:mysql:host='.$self->machine.
+                ':database='.$self->option('dbname');
+  DBI->connect(
+    $datasrc,
+    $self->option('username'),
+    $self->option('password'),
+    { AutoCommit => 0 }
+  ) or die $DBI::errstr;
+}
+
+sub export_insert  { my $self = shift; $self->dma_rm_queue('insert', @_) }
+sub export_delete  { my $self = shift; $self->dma_rm_queue('delete', @_) }
+sub export_replace { my $self = shift; $self->dma_rm_queue('replace', @_) }
+sub export_suspend { my $self = shift; $self->dma_rm_queue('suspend', @_) }
+sub export_unsuspend { my $self = shift; $self->dma_rm_queue('unsuspend', @_) }
+
+sub dma_rm_queue {
+  my ($self, $action, $svc_acct, $old) = @_;
+
+  my $svcnum = $svc_acct->svcnum;
+
+  my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
+  my $cust_main = $cust_pkg->cust_main;
+  my $location = $cust_pkg->cust_location;
+
+  my $address = $location->address1;
+  $address .= ' '.$location->address2 if $location->address2;
+  my $country = code2country($location->country);
+  my $lsc = Locale::SubCountry->new($location->country);
+  my $state = $lsc->full_name($location->state) if defined($lsc);
+
+  my %params = (
+    # for the remote side
+    username    => $svc_acct->username,
+    password    => md5_hex($svc_acct->_password),
+    groupid     => $self->option('groupid'),
+    enableuser  => 1,
+    firstname   => $cust_main->first,
+    lastname    => $cust_main->last,
+    company     => $cust_main->company,
+    phone       => ($cust_main->daytime || $cust_main->night),
+    mobile      => $cust_main->mobile,
+    address     => $location->address1, # address2?
+    city        => $location->city,
+    state       => $state, #full name
+    zip         => $location->zip,
+    country     => $country, #full name
+    gpslat      => $location->latitude,
+    gpslong     => $location->longitude,
+    comment     => 'svcnum'.$svcnum,
+    createdby   => $self->option('manager'),
+    owner       => $self->option('manager'),
+    email       => $cust_main->invoicing_list_emailonly_scalar,
+
+    # used internally by the export
+    exportnum   => $self->exportnum,
+    svcnum      => $svcnum,
+    action      => $action,
+    svcpart     => $svc_acct->cust_svc->svcpart,
+    _password   => $svc_acct->_password,
+  );
+  if ( $action eq 'replace' ) {
+    $params{'old_username'} = $old->username;
+    $params{'old_password'} = $old->_password;
+  }
+  my $queue = FS::queue->new({
+      'svcnum'  => $svcnum,
+      'job'     => "FS::part_export::dma_radiusmanager::dma_rm_action",
+  });
+  $queue->insert(%params);
+}
+
+sub dma_rm_action {
+  my %params = @_;
+  my $svcnum = delete $params{svcnum};
+  my $action = delete $params{action};
+  my $svcpart = delete $params{svcpart};
+  my $exportnum = delete $params{exportnum};
+
+  my $username = $params{username};
+  my $password = delete $params{_password};
+
+  my $self = FS::part_export->by_key($exportnum);
+  my $dbh = $self->connect;
+  local $DEBUG = 1 if $self->option('debug');
+
+  # export the part_svc if needed, and get its srvid
+  my $part_svc = FS::part_svc->by_key($svcpart);
+  my $srvid = $self->export_part_svc($part_svc, $dbh); # dies on error
+  $params{srvid} = $srvid;
+
+  if ( $action eq 'insert' ) {
+    $params{'createdon'} = time2str('%Y-%m-%d', time);
+    $params{'expiration'} = time2str('%Y-%m-%d', time);
+    warn "rm_users: inserting svcnum$svcnum\n" if $DEBUG;
+    my $sth = $dbh->prepare( 'INSERT INTO rm_users ( '.
+      join(', ', keys(%params)).
+      ') VALUES ('.
+      join(', ', ('?') x keys(%params)).
+      ')'
+    );
+    $sth->execute(values(%params)) or die $dbh->errstr;
+
+    # minor false laziness w/ sqlradius_insert
+    warn "radcheck: inserting $username\n" if $DEBUG;
+    $sth = $dbh->prepare( 'INSERT INTO radcheck (
+      username, attribute, op, value
+    ) VALUES (?, ?, ?, ?)' );
+    $sth->execute(
+      $username,
+      'Cleartext-Password',
+      ':=', # :=(
+      $password,
+    ) or die $dbh->errstr;
+
+    $sth->execute(
+      $username,
+      'Simultaneous-Use',
+      ':=',
+      1, # should this be an option?
+    ) or die $dbh->errstr;
+    # also, we don't support exporting any other radius attrs...
+    # those should go in 'custattr' if we need them
+  } elsif ( $action eq 'replace' ) {
+
+    my $old_username = delete $params{old_username};
+    my $old_password = delete $params{old_password};
+    # svcnum is invariant and on the remote side, so we don't need any 
+    # of the old fields to do this
+    warn "rm_users: updating svcnum$svcnum\n" if $DEBUG;
+    my $sth = $dbh->prepare( 'UPDATE rm_users SET '.
+      join(', ', map { "$_ = ?" } keys(%params)).
+      ' WHERE comment = ?'
+    );
+    $sth->execute(values(%params), $params{comment}) or die $dbh->errstr;
+    # except for username/password changes
+    if ( $old_password ne $password ) {
+      warn "radcheck: changing password for $old_username\n" if $DEBUG;
+      $sth = $dbh->prepare( 'UPDATE radcheck SET value = ? '.
+        'WHERE username = ? and attribute = \'Cleartext-Password\''
+      );
+      $sth->execute($password, $old_username) or die $dbh->errstr;
+    }
+    if ( $old_username ne $username ) {
+      warn "radcheck: changing username $old_username to $username\n"
+        if $DEBUG;
+      $sth = $dbh->prepare( 'UPDATE radcheck SET username = ? '.
+        'WHERE username = ?'
+      );
+      $sth->execute($username, $old_username) or die $dbh->errstr;
+    }
+
+  } elsif ( $action eq 'suspend' ) {
+
+    # this is sufficient
+    warn "rm_users: disabling svcnum#$svcnum\n" if $DEBUG;
+    my $sth = $dbh->prepare( 'UPDATE rm_users SET enableuser = 0 '.
+      'WHERE comment = ?'
+    );
+    $sth->execute($params{comment}) or die $dbh->errstr;
+
+  } elsif ( $action eq 'unsuspend' ) {
+
+    warn "rm_users: enabling svcnum#$svcnum\n" if $DEBUG;
+    my $sth = $dbh->prepare( 'UPDATE rm_users SET enableuser = 1 '.
+      'WHERE comment = ?'
+    );
+    $sth->execute($params{comment}) or die $dbh->errstr;
+
+  } elsif ( $action eq 'delete' ) {
+
+    warn "rm_users: deleting svcnum#$svcnum\n" if $DEBUG;
+    my $sth = $dbh->prepare( 'DELETE FROM rm_users WHERE comment = ?' );
+    $sth->execute($params{comment}) or die $dbh->errstr;
+
+    warn "radcheck: deleting $username\n" if $DEBUG;
+    $sth = $dbh->prepare( 'DELETE FROM radcheck WHERE username = ?' );
+    $sth->execute($username) or die $dbh->errstr;
+
+    # if this were smarter it would also delete the rm_services record
+    # if it was no longer in use, but that's not really necessary
+  }
+
+  $dbh->commit;
+  '';
+}
+
+=item export_part_svc PART_SVC DBH
+
+Query Radius Manager for a service definition matching the name of 
+PART_SVC (optionally with a prefix defined in the export options).  
+If there is one, update it to match the attributes of PART_SVC; if 
+not, create one.  Then return its srvid.
+
+=cut
+
+sub export_part_svc {
+  my ($self, $part_svc, $dbh) = @_;
+
+  my $name = $self->option('service_prefix').$part_svc->svc;
+
+  my %params = (
+    'srvname'         => $name,
+    'enableservice'   => 1,
+    'nextsrvid'       => -1,
+    'dailynextsrvid'  => -1,
+  );
+  my @fixed_groups;
+  # use speed settings from fixed usergroups configured on this part_svc
+  if ( my $psc = $part_svc->part_svc_column('usergroup') ) {
+    if ( $psc->columnflag eq 'F' )  {
+      # each part_svc really should only have one fixed group with non-null 
+      # speed settings, but go by priority order for consistency
+      @fixed_groups = 
+        sort { $a->priority <=> $b->priority }
+        grep { $_ }
+        map { FS::radius_group->by_key($_) }
+        split(/\s*,\s*/, $psc->columnvalue);
+    }
+  } # otherwise there are no fixed groups, so leave speed empty
+
+  foreach (qw(down up)) {
+    my $speed = "speed_$_";
+    foreach my $group (@fixed_groups) {
+      if ( ($group->$speed || 0) > 0 ) {
+        $params{$_.'rate'} = $group->$speed;
+        last;
+      }
+    }
+  }
+  # anything else we need here? poolname, maybe?
+
+  warn "rm_services: looking for '$name'\n" if $DEBUG;
+  my $sth = $dbh->prepare( 
+    'SELECT srvid FROM rm_services WHERE srvname = ? AND enableservice = 1'
+  );
+  $sth->execute($name) or die $dbh->errstr;
+  if ( $sth->rows > 1 ) {
+    die "Multiple services with name '$name' found in Radius Manager.\n";
+  } elsif ( $sth->rows == 1 ) {
+    my $row = $sth->fetchrow_arrayref;
+    my $srvid = $row->[0];
+    warn "rm_services: updating srvid#$srvid\n" if $DEBUG;
+    $sth = $dbh->prepare(
+      'UPDATE rm_services SET '.join(', ', map {"$_ = ?"} keys %params) .
+      ' WHERE srvid = ?'
+    );
+    $sth->execute(values(%params), $srvid) or die $dbh->errstr;
+    return $srvid;
+  } else { # $sth->rows == 0
+    # create a new one
+    # but first... get the next available srvid
+    $sth = $dbh->prepare('SELECT MAX(srvid) FROM rm_services');
+    $sth->execute or die $dbh->errstr;
+    my $srvid = 1; # just in case you somehow have nothing in your database
+    if ( $sth->rows ) {
+      $srvid = $sth->fetchrow_arrayref->[0] + 1;
+    }
+    $params{'srvid'} = $srvid;
+    # NOW create a new one
+    warn "rm_services: inserting '$name' as srvid#$srvid\n" if $DEBUG;
+    $sth = $dbh->prepare(
+      'INSERT INTO rm_services ('.join(', ', keys %params).
+      ') VALUES ('.join(', ', map {'?'} keys %params).')'
+    );
+    $sth->execute(values(%params)) or die $dbh->errstr;
+    # also link it to our manager name
+    warn "rm_services: linking to manager\n" if $DEBUG;
+    $sth = $dbh->prepare(
+      'INSERT INTO rm_allowedmanagers (srvid, managername) VALUES (?, ?)'
+    );
+    $sth->execute($srvid, $self->option('manager')) or die $dbh->errstr;
+    # and allow it on our NAS
+    $sth = $dbh->prepare(
+      'INSERT INTO rm_allowednases (srvid, nasid) VALUES (?, ?)'
+    );
+    foreach my $nasid ($self->nas_ids($dbh)) {
+      warn "rm_services: linking to nasid#$nasid\n" if $DEBUG;
+      $sth->execute($srvid, $nasid) or die $dbh->errstr;
+    }
+    return $srvid;
+  }
+}
+
+=item nas_ids DBH
+
+Convert the 'nasnames  option into a list of real NAS ids.
+
+=cut
+
+sub nas_ids {
+  my $self = shift;
+  my $dbh = shift;
+
+  my @nasnames = split(/\s*,\s*/, $self->option('nasnames'));
+  return unless @nasnames;
+  # pass these through unchanged
+  my @ids = grep { /^\d+$/ } @nasnames;
+  @nasnames = grep { not /^\d+$/ } @nasnames;
+  if ( @nasnames ) {
+    my $in_nasnames = join(',', map {$dbh->quote($_)} @nasnames);
+
+    my $sth = $dbh->prepare("SELECT id FROM nas WHERE nasname IN ($in_nasnames)");
+    $sth->execute or die $dbh->errstr;
+    my $rows = $sth->fetchall_arrayref;
+    push @ids, $_->[0] foreach @$rows;
+  }
+
+  return @ids;
+}
+
+1;
index 0749fec..ff0d949 100644 (file)
@@ -26,6 +26,7 @@ my $postfix_transport_static =
   'desc'    => 'Real time export of domains to SQL databases '.
                '(postfix, others?)',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<END
 Export domains (svc_domain records) to SQL databases.  Currently this is a
 simple export with a default for Postfix, but it can be extended for other
index 0fd32fa..7386973 100644 (file)
@@ -18,6 +18,8 @@ tie my %options, 'Tie::IxHash',
   'svc'    => 'svc_acct',
   'desc'   => 'Real-time export to Everyone.net outsourced mail service',
   'options'=> \%options,
+  'no_machine' => 1,
+  'default_svc_class' => 'Email',
   'notes'  => <<'END'
 Real-time export to
 <a href="http://www.everyone.net/">Everyone.net</a> via the XRC Remote API.
diff --git a/FS/FS/part_export/ez_prepaid.pm b/FS/FS/part_export/ez_prepaid.pm
new file mode 100644 (file)
index 0000000..9f454df
--- /dev/null
@@ -0,0 +1,184 @@
+package FS::part_export::ez_prepaid;
+
+use base qw( FS::part_export );
+
+use strict;
+use vars qw(@ISA %info $version $replace_ok_kludge $product_info);
+use Tie::IxHash;
+use FS::Record qw( qsearchs );
+use FS::svc_external;
+use SOAP::Lite;
+use XML::Simple qw( xml_in );
+use Data::Dumper;
+
+$version = '01';
+
+my $product_info;
+my %language_id = ( English => 1, Spanish => 2 );
+
+tie my %options, 'Tie::IxHash',
+  'site_id'     => { label => 'Site ID' },
+  'clerk_id'    => { label => 'Clerk ID' },
+#  'product_id'  => { label => 'Product ID' }, use the 'title' field
+#  'amount'      => { label => 'Purchase amount' },
+  'language'    => { label => 'Language',
+                     type  => 'select',
+                     options => [ 'English', 'Spanish' ],
+                    },
+
+  'debug'       => { label => 'Debug level',
+                     type  => 'select', options => [0, 1, 2 ] },
+;
+
+%info = (
+  'svc'     => 'svc_external',
+  'desc'    => 'Purchase EZ-Prepaid PIN',
+  'options' => \%options,
+  'no_machine' => 1,
+  'notes'   => <<'END'
+<P>Export to the EZ-Prepaid PIN purchase service.  If the purchase is allowed,
+the PIN will be stored as svc_external.id.</P>
+<P>svc_external.title must contain the product ID, and should be set as a fixed
+field in the service definition.  For a list of product IDs, see the 
+"Merchant Info" tab in the EZ Prepaid reseller portal.</P>
+END
+  );
+
+$replace_ok_kludge = 0;
+
+sub _export_insert {
+  my ($self, $svc_external) = @_;
+
+  # the name on the certificate is 'debisys.com', for some reason
+  local $ENV{PERL_LWP_SSL_VERIFY_HOSTNAME}=0;
+
+  my $pin = eval { $self->ez_prepaid_PinDistSale( $svc_external->title ) };
+  return $@ if $@;
+
+  local($replace_ok_kludge) = 1;
+  $svc_external->set('id', $pin);
+  $svc_external->replace;
+}
+
+sub _export_replace {
+  $replace_ok_kludge ? '' : "can't change PIN after purchase";
+}
+
+sub _export_delete {
+  "can't delete PIN after purchase";
+}
+
+# possibly options at some point to relate these to agentnum/usernum
+sub site_id { $_[0]->option('site_id') }
+
+sub clerk_id { $_[0]->option('clerk_id') }
+
+sub ez_prepaid_PinDistSale {
+  my $self = shift;
+  my $product_id = shift;
+  $self->ez_prepaid_init; # populate product ID cache
+  my $info = $product_info->{$product_id};
+  if ( $info ) {
+    if ( $self->option('debug') ) {
+      warn "Purchasing PIN product #$product_id:\n" .
+            $info->{Description}."\n".
+            $info->{CurrencyCode} . ' ' .$info->{Amount}."\n";
+    }
+  } else { #no $info
+    die "Unknown PIN product #$product_id.\n";
+  }
+
+  my $response = $self->ez_prepaid_request(
+    'PinDistSale',
+    $version,
+    $self->site_id,
+    $self->clerk_id,
+    $product_id,
+    '', # AccountID, not used for PIN sale
+    $product_info->{$product_id}->{Amount},
+    $self->svcnum,
+    ($language_id{ $self->option('language') } || 1),
+  );
+  if ( $self->option('debug') ) {
+    warn Dumper($response);
+    # includes serial number and transaction ID, possibly useful
+    # (but we don't have a structured place to store it--maybe in 
+    # a customer note?)
+  }
+  $response->{Pin};
+}
+
+sub ez_prepaid_init {
+  # returns the SOAP client object
+  my $self = shift;
+  my $wsdl = 'https://webservice.ez-prepaid.com/soap/webServices.wsdl';
+
+  if ( $self->option('debug') >= 2 ) {
+    SOAP::Lite->import(+trace => [transport => \&log_transport ]);
+  }
+  if ( !$self->client ) {
+    $self->set(client => SOAP::Lite->new->service($wsdl));
+    # I don't know if this can happen, but better to bail out here
+    # than go into recursion.
+    die "Error creating SOAP client\n" if !$self->client;
+  }
+
+  if ( !defined($product_info) ) {
+    # for now we only support the 'PIN' type
+    my $response = $self->ez_prepaid_request(
+      'GetTransTypeList', $version, $self->site_id, '', '', '', ''
+    );
+    my %transtype = map { $_->{Description} => $_->{TransTypeId} }
+      @{ $response->{TransType} };
+
+    if ( !exists $transtype{PIN} ) {
+      warn "'PIN' transaction type not available.\n";
+      # or else your site ID is wrong
+      return;
+    }
+
+    $response = $self->ez_prepaid_request(
+      'GetProductList',
+      $version,
+      $self->option('site_id'),
+      $transtype{PIN},
+      '', #CarrierId
+      '', #CategoryId
+      '', #ProductId
+    );
+    $product_info = +{
+      map { $_->{ProductId} => $_ }
+      @{ $response->{Product} }
+    };
+  } #!defined $product_info
+}
+
+sub log_transport {
+  my $in = shift;
+  if ( UNIVERSAL::can($in, 'content') ) {
+    warn $in->content."\n";
+  }
+}
+
+my @ForceArray = qw(TransType Product); # add others as needed
+sub ez_prepaid_request {
+  my $self = shift;
+  # takes a method name and param list,
+  # returns a hashref containing the unpacked response
+  # or dies on error
+  
+  $self->ez_prepaid_init if !$self->client;
+
+  my $method = shift;
+  my $xml = $self->client->$method(@_);
+  # All of their response data types are one part, a string, containing 
+  # an encoded XML structure, containing the fields described in the docs.
+  my $response = xml_in($xml, ForceArray => \@ForceArray);
+  if ( exists($response->{ResponseCode}) && $response->{ResponseCode} > 0 ) {
+    die "[$method] ".$response->{ResponseMessage};
+  }
+  $response;
+}
+
+1;
index 563efcc..eb41378 100644 (file)
@@ -10,6 +10,7 @@ use FS::Record;
   'desc'     => 'Real-time export of forwards to SQL databases ',
                 #.' (vpopmail, Postfix+Courier IMAP, others?)',
   'options'  => __PACKAGE__->sql_options,
+  'no_machine' => 1,
   'notes'    => <<END
 Export mail forwards (svc_forward records) to SQL databases.
 
diff --git a/FS/FS/part_export/freeswitch.pm b/FS/FS/part_export/freeswitch.pm
new file mode 100644 (file)
index 0000000..eb490fd
--- /dev/null
@@ -0,0 +1,192 @@
+package FS::part_export::freeswitch;
+use base qw( FS::part_export );
+
+use vars qw( %info ); # $DEBUG );
+#use Data::Dumper;
+use Tie::IxHash;
+use Text::Template;
+use FS::Record qw( qsearch ); #qsearchs );
+use FS::svc_phone;
+#use FS::Schema qw( dbdef );
+
+#$DEBUG = 1;
+
+tie my %options, 'Tie::IxHash',
+  'user'  => { label => 'SSH username', default=>'root', },
+  'directory' => { label   => 'Directory to store FreeSWITCH account XML files',
+                   default => '/usr/local/freeswitch/conf/directory/',
+                 },
+  #'domain'    => { label => 'Optional fixed SIP domain to use, overrides svc_phone domain', },
+  'reload'    => { label   => 'Reload command',
+                   default => '/usr/local/freeswitch/bin/fs_cli -x reloadxml',
+                 },
+  'user_template' => { label   => 'User XML configuration template',
+                       type    => 'textarea',
+                       default => <<'END',
+<domain name="<% $domain %>">
+  <user id="<% $phonenum %>">
+    <params>
+      <param name="password" value="<% $sip_password %>"/>
+    </params>
+  </user>
+</domain>
+END
+                     },
+;
+
+%info = (
+  'svc'     => 'svc_phone',
+  'desc'    => 'Provision phone services to FreeSWITCH XML configuration files',
+  'options' => \%options,
+  'notes'   => <<'END',
+Export XML account configuration files to FreeSWITCH, one per domain.
+<br><br>
+You will need to enable the svc_phone-domain configuration setting and
+<a href="http://www.freeside.biz/mediawiki/index.php/Freeside:1.9:Documentation:Administration:SSH_Keys">setup SSH for unattended operation</a>.
+END
+);
+
+sub rebless { shift; }
+
+sub _export_insert {
+  my( $self, $svc_phone ) = ( shift, shift );
+
+  $self->_export_rebuild_domain($svc_phone);
+
+}
+
+sub _export_replace {
+  my( $self, $new, $old ) = ( shift, shift, shift );
+
+  my $error = $self->_export_rebuild_domain($new);
+  return $error if $error;
+
+  if ( $new->domsvc ne $old->domsvc && $old->domsvc ) {
+    $error = $self->_export_rebuild_domain($old);
+    return $error if $error;
+  }
+
+  '';
+}
+
+sub _export_delete {
+  my( $self, $svc_phone ) = ( shift, shift );
+
+  $self->_export_rebuild_domain($svc_phone);
+}
+
+sub _export_rebuild_domain {
+  my($self, $svc_phone) = ( shift, shift );
+
+  eval "use Net::SCP;";
+  die $@ if $@;
+
+  #create and copy over file
+
+  my $tempdir = '%%%FREESIDE_CONF%%%/cache.'. $FS::UID::datasrc;
+
+  my $domain = $svc_phone->domain or return "domain required";
+
+  my $fh = new File::Temp(
+    TEMPLATE => "$tempdir/freeswitch.$domain.XXXXXXXX",
+    DIR      => $dir,
+    #UNLINK   => 0,
+  );
+
+  print $fh qq(<domain name="$domain">\n);
+
+  my @dom_svc_phone = qsearch( 'svc_phone', { 'domsvc'=>$svc_phone->domsvc } );
+
+  foreach my $dom_svc_phone (@dom_svc_phone) {
+
+    print $fh $self->freeswitch_template_fillin( $dom_svc_phone, 'user' )
+      or die "print to freeswitch template failed: $!";
+
+  }
+
+  print $fh qq(</domain>\n);
+  $fh->flush;
+
+  my $scp = new Net::SCP;
+  my $user = $self->option('user')||'root';
+  my $host = $self->machine;
+  my $dir = $self->option('directory');
+
+  $scp->scp( $fh->filename, "$user\@$host:$dir/$domain.xml" )
+    or return $scp->{errstr};
+
+  #signal freeswitch to reload config
+  $self->freeswitch_ssh( command => $self->option('reload') );
+
+  '';
+
+}
+
+sub freeswitch_template_fillin {
+  my( $self, $svc_phone, $template ) = (shift, shift, shift);
+
+  $template ||= 'user'; #?
+
+  #cache a %tt hash?
+  my $tt = new Text::Template (
+    TYPE       => 'STRING',
+    SOURCE     => $self->option($template.'_template'),
+    DELIMITERS => [ '<%', '%>' ],
+  );
+
+  #false lazinessish w/phone_shellcommands::_export_command
+  my %hash = (
+    map { $_ => $svc_phone->getfield($_) } $svc_phone->fields
+  );
+
+  #might as well do em all, they're all going in an XML file as attribs
+  foreach ( keys %hash ) {
+    $hash{$_} =~ s/'/&apos;/g;
+    $hash{$_} =~ s/"/&quot;/g;
+  }
+
+  $tt->fill_in(
+    HASH => \%hash,
+  );
+}
+
+##a good idea to queue anything that could fail or take any time
+#sub shellcommands_queue {
+#  my( $self, $svcnum ) = (shift, shift);
+#  my $queue = new FS::queue {
+#    'svcnum' => $svcnum,
+#    'job'    => "FS::part_export::freeswitch::ssh_cmd",
+#  };
+#  $queue->insert( @_ );
+#}
+
+sub freeswitch_ssh { #method
+  my $self = shift;
+  ssh_cmd( user    => $self->option('user')||'root',
+           host    => $self->machine,
+           @_,
+         );
+}
+
+sub ssh_cmd { #subroutine, not method
+  use Net::OpenSSH;
+  my $opt = { @_ };
+  open my $def_in, '<', '/dev/null' or die "unable to open /dev/null";
+  my $ssh = Net::OpenSSH->new( $opt->{'user'}.'@'.$opt->{'host'},
+                               default_stdin_fh => $def_in,
+                             );
+  die "Couldn't establish SSH connection: ". $ssh->error if $ssh->error;
+  my ($output, $errput) = $ssh->capture2( #{stdin_discard => 1},
+                                          $opt->{'command'}
+                                        );
+  die "Error running SSH command: ". $ssh->error if $ssh->error;
+
+  #who the fuck knows what freeswitch reload outputs, probably a fucking
+  # ascii advertisement for cluecon
+  #die $errput if $errput;
+  #die $output if $output;
+
+  '';
+}
+
+1;
index 6df21f4..9fe45ba 100644 (file)
@@ -19,6 +19,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_phone',
   'desc'    => 'Provision phone numbers to VoIP Innovations (formerly GlobalPOPs VoIP)',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Requires installation of
 <a href="http://search.cpan.org/dist/Net-GlobalPOPs-MediaServicesAPI">Net::GlobalPOPs::MediaServicesAPI</a>
index 3749224..c35c89f 100644 (file)
@@ -43,6 +43,7 @@ tie %options, 'Tie::IxHash',
   'svc'     => 'svc_domain',
   'desc'    => 'Send an HTTP or HTTPS GET or POST request',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Send an HTTP or HTTPS GET or POST to the specified URL.  For HTTPS support,
 <a href="http://search.cpan.org/dist/Crypt-SSLeay">Crypt::SSLeay</a>
index 5342106..6fbd3fb 100644 (file)
@@ -17,6 +17,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_dsl',
   'desc'    => 'Retrieve status information via HTTP or HTTPS',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Fields from the service can be substituted in the URL as $field.
 END
index eedc9d0..23917bf 100644 (file)
@@ -31,6 +31,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_dsl',
   'desc'    => 'Provision DSL to Ikano',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Requires installation of
 <a href="http://search.cpan.org/dist/Net-Ikano">Net::Ikano</a> from CPAN.
index b573401..02ae5ef 100644 (file)
@@ -17,6 +17,7 @@ tie my %options, 'Tie::IxHash',
   'desc'     =>
     'Export conferences to the Indosoft Conference Bridge',
   'options'  => \%options,
+  'no_machine' => 1,
   'notes'    => <<'END'
 Export conferences to the Indosoft conference bridge.
 Net::Indosoft::Voicebridge is required.
index ef16c7c..51f5760 100644 (file)
@@ -19,6 +19,7 @@ tie my %options, 'Tie::IxHash',
   'desc'     => 'Real-time export to InfoStreet streetSmartAPI',
   'options'  => \%options,
   'nodomain' => 'Y',
+  'no_machine' => 1,
   'notes'    => <<'END'
 Real-time export to
 <a href="http://www.infostreet.com/">InfoStreet</a> streetSmartAPI.
index a94e43e..b51f631 100644 (file)
@@ -17,6 +17,7 @@ tie my %options, 'Tie::IxHash',
   'desc'    => 'Provision phone numbers from the internal DID database',
   'notes'   => 'After adding the export, DIDs may be imported under Tools -> Importing -> Import phone numbers (DIDs)',
   'options' => \%options,
+  'no_machine' => 1,
 );
 
 sub rebless { shift; }
index 8385320..fe634d2 100644 (file)
@@ -41,6 +41,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_acct',
   'desc'    => 'Real-time export to LDAP',
   'options' => \%options,
+  'default_svc_class' => 'Email',
   'notes'   => <<'END'
 Real-time export to arbitrary LDAP attributes.  Requires installation of
 <a href="http://search.cpan.org/dist/Net-LDAP">Net::LDAP</a> from CPAN.
index 6e2ee8a..2e37d04 100644 (file)
@@ -72,10 +72,11 @@ tie my %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'      => [ 'svc_phone', ], # 'part_device',
-  'desc'     => 'Provision phone numbers to NetSapiens',
-  'options'  => \%options,
-  'notes'    => <<'END'
+  'svc'        => [ 'svc_phone', ], # 'part_device',
+  'desc'       => 'Provision phone numbers to NetSapiens',
+  'options'    => \%options,
+  'no_machine' => 1,
+  'notes'      => <<'END'
 Requires installation of
 <a href="http://search.cpan.org/dist/REST-Client">REST::Client</a>
 from CPAN.
index 0145af3..3a76488 100644 (file)
@@ -11,3 +11,4 @@ sub _export_insert {}
 sub _export_replace {}
 sub _export_delete {}
 
+1;
index 040af27..5c1ae01 100644 (file)
@@ -138,3 +138,4 @@ sub ssh_cmd { #subroutine, not method
   &Net::SSH::ssh_cmd( { @_ } );
 }
 
+1;
index 3d01c16..7b07ecf 100644 (file)
@@ -21,10 +21,11 @@ tie %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'      => 'svc_phone',
-  'desc'     => 'Export DIDs to OpenSIPs dr_rules table',
-  'options'  => \%options,
-  'notes'    => 'Export DIDs to OpenSIPs dr_rules table',
+  'svc'        => 'svc_phone',
+  'desc'       => 'Export DIDs to OpenSIPs dr_rules table',
+  'options'    => \%options,
+  'no_machine' => 1,
+  'notes'      => 'Export DIDs to OpenSIPs dr_rules table',
 );
 
 sub rebless { shift; }
@@ -93,3 +94,4 @@ sub dr_reload {
     '';
 }
 
+1;
index 6b14bed..46c372c 100644 (file)
@@ -39,10 +39,11 @@ tie %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'      => 'svc_phone',
-  'desc'     => 'Real-time export to SQL-backed RADIUS (FreeRADIUS, ICRADIUS) for phone provisioning and rating',
-  'options'  => \%options,
-  'notes'    => <<END,
+  'svc'        => 'svc_phone',
+  'desc'       => 'Real-time export to SQL-backed RADIUS (FreeRADIUS, ICRADIUS) for phone provisioning and rating',
+  'options'    => \%options,
+  'no_machine' => 1,
+  'notes'      => <<END,
 Real-time export of <b>radcheck</b> table
 to any SQL database for <a href="http://www.freeradius.org/">FreeRADIUS</a>
 or <a href="http://radius.innercite.com/">ICRADIUS</a>.
index 4fd19ee..9a8d617 100644 (file)
@@ -22,6 +22,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_forward',
   'desc'    => 'Postfix text files',
   'options' => \%options,
+  'default_svc_class' => 'Email',
   'notes'   => <<'END'
 Batch export of Postfix aliases and virtual files.
 <a href="http://search.cpan.org/dist/File-Rsync">File::Rsync</a>
index 02e89c6..9964489 100644 (file)
@@ -79,11 +79,12 @@ possibly harmful.
 EOT
 
 %info = (
-  'svc'      => 'svc_broadband',
-  'desc'     => 'Real-time export to Northbound Interface',
-  'options'  => \%options,
-  'nodomain' => 'Y',
-  'notes'    => $notes,
+  'svc'        => 'svc_broadband',
+  'desc'       => 'Real-time export to Northbound Interface',
+  'options'    => \%options,
+  'nodomain'   => 'Y',
+  'no_machine' => 1,
+  'notes'      => $notes,
 );
 
 sub prizm_command {
index 2ac3edb..f09d36a 100644 (file)
@@ -11,6 +11,8 @@ tie my %options, 'Tie::IxHash', %FS::part_export::sqlradius::options;
   'desc'     => 'Real-time export to RADIATOR',
   'options'  => \%options,
   'nodomain' => '',
+  'no_machine' => 1,
+  'default_svc_class' => 'Internet',
   'notes' => <<'END',
 Real-time export of the <b>radusers</b> table to any SQL database in
 <a href="http://www.open.com.au/radiator/">Radiator</a>-native format.
index 6a1d676..3071ece 100644 (file)
@@ -87,6 +87,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_broadband',
   'desc'    => 'Send a command to a router.',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => 'Installation of Net::Telnet from CPAN is required for telnet connections.  This export will execute if the following virtual fields are set on the router: admin_user, admin_password, admin_address, admin_timeout, admin_prompt.  Option virtual fields are: admin_cmd_insert, admin_cmd_replace, admin_cmd_delete, admin_cmd_suspend, admin_cmd_unsuspend.  See the module documentation for a full list of required/supported router virtual fields.',
 );
 
index b53b7da..7ae6105 100644 (file)
@@ -127,6 +127,7 @@ tie my %options, 'Tie::IxHash', (
   'Create an RT ticket',
   'options'  => \%options,
   'nodomain' => '',
+  'no_machine' => 1,
   'notes'    => ' 
   Create a ticket in RT.  The subject and body of the ticket 
   will be generated from a message template.'
index 05f6236..6ba131f 100644 (file)
@@ -85,6 +85,7 @@ tie my %options, 'Tie::IxHash', (
   'Send an email message',
   'options'  => \%options,
   'nodomain' => '',
+  'no_machine' => 1,
   'notes'    => ' 
   Send an email message.  The subject and body of the message
   will be generated from a message template.'
index 20e9091..f964af3 100644 (file)
@@ -97,12 +97,12 @@ tie my %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'      => 'svc_acct',
-  'desc'     =>
-    'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
-  'options'  => \%options,
-  'nodomain' => 'Y',
-  'notes' => <<'END'
+  'svc'         => 'svc_acct',
+  'desc'        => 'Real-time export via remote SSH (i.e. useradd, userdel, etc.)',
+  'options'     => \%options,
+  'nodomain'    => 'Y',
+  'svc_machine' => 1,
+  'notes'       => <<'END'
 Run remote commands via SSH.  Usernames are considered unique (also see
 shellcommands_withdomain).  You probably want this if the commands you are
 running will not accept a domain as a parameter.  You will need to
@@ -124,24 +124,7 @@ running will not accept a domain as a parameter.  You will need to
       this.form.unsuspend_stdin.value="";
     '>
   <LI>
-    <INPUT TYPE="button" VALUE="FreeBSD before 4.10 / 5.3" onClick='
-      this.form.useradd.value = "lockf /etc/passwd.lock pw useradd $username -d $dir -m -s $shell -u $uid -c $finger -h 0";
-      this.form.useradd_stdin.value = "$_password\n";
-      this.form.userdel.value = "lockf /etc/passwd.lock pw userdel $username -r"; this.form.userdel_stdin.value="";
-      this.form.usermod.value = "lockf /etc/passwd.lock pw usermod $old_username -d $new_dir -m -l $new_username -s $new_shell -u $new_uid -g $new_gid -c $new_finger -h 0";
-      this.form.usermod_stdin.value = "$new__password\n"; this.form.suspend.value = "lockf /etc/passwd.lock pw lock $username";
-      this.form.suspend_stdin.value="";
-      this.form.unsuspend.value = "lockf /etc/passwd.lock pw unlock $username"; this.form.unsuspend_stdin.value="";
-    '>
-    Note: On FreeBSD versions before 5.3 and 4.10 (4.10 is after 4.9, not
-    4.1!), due to deficient locking in pw(1), you must disable the chpass(1),
-    chsh(1), chfn(1), passwd(1), and vipw(1) commands, or replace them with
-    wrappers that prepend "lockf /etc/passwd.lock".  Alternatively, apply the
-    patch in
-    <A HREF="http://www.freebsd.org/cgi/query-pr.cgi?pr=23501">FreeBSD PR#23501</A>
-    and use the "FreeBSD 4.10 / 5.3 or later" button below.
-  <LI>
-    <INPUT TYPE="button" VALUE="FreeBSD 4.10 / 5.3 or later" onClick='
+    <INPUT TYPE="button" VALUE="FreeBSD" onClick='
       this.form.useradd.value = "pw useradd $username -d $dir -m -s $shell -u $uid -g $gid -c $finger -h 0";
       this.form.useradd_stdin.value = "$_password\n";
       this.form.userdel.value = "pw userdel $username -r";
@@ -360,7 +343,7 @@ sub _export_command {
 
   my @ssh_cmd_args = (
     user          => $self->option('user') || 'root',
-    host          => $self->machine,
+    host          => $self->svc_machine($svc_acct),
     command       => $command_string,
     stdin_string  => $stdin_string,
     ignored_errors    => $self->option('ignored_errors') || '',
@@ -373,7 +356,7 @@ sub _export_command {
     eval { ssh_cmd(@ssh_cmd_args) };
     $error = $@;
     $error = $error->full_message if ref $error; # Exception::Class::Base
-    return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
+    return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($svc_acct). ')'
       if $error;
   }
   else {
@@ -433,7 +416,7 @@ sub _export_replace {
     #  $error ||= "can't change RADIUS groups";
     #}
   }
-  return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
+  return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
     if $error;
 
   $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
@@ -457,7 +440,7 @@ sub _export_replace {
 
   my @ssh_cmd_args = (
     user          => $self->option('user') || 'root',
-    host          => $self->machine,
+    host          => $self->svc_machine($new),
     command       => $command_string,
     stdin_string  => $stdin_string,
     ignored_errors    => $self->option('ignored_errors') || '',
@@ -470,7 +453,7 @@ sub _export_replace {
     eval { ssh_cmd(@ssh_cmd_args) };
     $error = $@;
     $error = $error->full_message if ref $error; # Exception::Class::Base
-    return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
+    return $error. ' ('. $self->exporttype. ' to '. $self->svc_machine($new). ')'
       if $error;
   }
   else {
@@ -507,7 +490,7 @@ sub ssh_cmd { #subroutine, not method
   my ($output, $errput) = $ssh->capture2($ssh_opt, $opt->{'command'});
 
   return if $opt->{'ignore_all_errors'};
-  die "Error running SSH command: ". $ssh->error if $ssh->error;
+  #die "Error running SSH command: ". $ssh->error if $ssh->error;
 
   if ( ($output || $errput)
        && $opt->{'ignored_errors'} && length($opt->{'ignored_errors'})
@@ -521,7 +504,9 @@ sub ssh_cmd { #subroutine, not method
     $errput =~ s/[\s\n]//g;
   }
 
-  die "$errput\n" if $errput;
+  die (($errput || $ssh->error). "\n") if $errput || $ssh->error; 
+  #die "$errput\n" if $errput;
+
   die "$output\n" if $output and $opt->{'fail_on_output'};
   '';
 }
index 1ebf5f6..1b59589 100644 (file)
@@ -80,10 +80,11 @@ tie my %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'     => 'svc_acct',
-  'desc'    => 'Real-time export via remote SSH (vpopmail, ISPMan)',
-  'options' => \%options,
-  'notes'   => <<'END'
+  'svc'         => 'svc_acct',
+  'desc'        => 'Real-time export via remote SSH (vpopmail, ISPMan, MagicMail)',
+  'options'     => \%options,
+  'svc_machine' => 1,
+  'notes'       => <<'END'
 Run remote commands via SSH.  username@domain (rather than just usernames) are
 considered unique (also see shellcommands).  You probably want this if the
 commands you are running will accept a domain as a parameter, and will allow
index cbdaf7f..19505b4 100644 (file)
@@ -37,6 +37,7 @@ tie my %options, 'Tie::IxHash',
   'desc'     => 'Real-time export to SQL-backed mail server',
   'options'  => \%options,
   'nodomain' => '',
+  'default_svc_class' => 'Email',
   'notes'    => <<'END'
 Database schema can be made to work with Courier IMAP, Exim and Dovecot.
 Others could work but are untested.  (more detailed description from
index c360c9e..6760d09 100644 (file)
@@ -110,6 +110,7 @@ END
   'desc'     => 'Real-time export to SQL-backed RADIUS (FreeRADIUS, ICRADIUS)',
   'options'  => \%options,
   'nodomain' => 'Y',
+  'no_machine' => 1,
   'nas'      => 'Y', # show export_nas selection in UI
   'default_svc_class' => 'Internet',
   'notes'    => $notes1.
@@ -347,6 +348,7 @@ sub _export_delete {
 
 sub sqlradius_queue {
   my( $self, $svcnum, $method ) = (shift, shift, shift);
+  my %args = @_;
   my $queue = new FS::queue {
     'svcnum' => $svcnum,
     'job'    => "FS::part_export::sqlradius::sqlradius_$method",
@@ -966,8 +968,7 @@ are identified by the combination of group name and attribute name.
 
 In the special case where attributes are being replaced because a group 
 name (L<FS::radius_group>->groupname) is changing, the pseudo-field 
-'groupname' must be set in OLD_RADIUS_ATTR.  It's probably best to do this
-
+'groupname' must be set in OLD_RADIUS_ATTR.
 
 =cut
 
@@ -982,41 +983,43 @@ sub export_attr_replace { shift->export_attr_action('replace', @_); }
 sub export_attr_action {
   my $self = shift;
   my ($action, $new, $old) = @_;
-  my ($attrname, $attrtype, $groupname) = 
-    ($new->attrname, $new->attrtype, $new->radius_group->groupname);
-  if ( $action eq 'replace' ) {
-
-    if ( $new->attrtype ne $old->attrtype ) {
-      # they're in separate tables in the target
-      return $self->export_attr_action('delete', $old) 
-          || $self->export_attr_action('insert', $new)
-      ;
-    }
+  my $err_or_queue;
 
-    # otherwise, just make sure we know the old attribute/group names 
-    # so we can find the existing record
-    $attrname = $old->attrname;
-    $groupname = $old->groupname || $old->radius_group->groupname;
-    # maybe this should be enforced more strictly
-    warn "WARNING: attribute replace without 'groupname' set; assuming '$groupname'\n"
-      if !defined($old->groupname);
+  if ( $action eq 'delete' ) {
+    $old = $new;
+  }
+  if ( $action eq 'delete' or $action eq 'replace' ) {
+    # delete based on an exact match
+    my %opt = (
+      attrname  => $old->attrname,
+      attrtype  => $old->attrtype,
+      groupname => $old->groupname || $old->radius_group->groupname,
+      op        => $old->op,
+      value     => $old->value,
+    );
+    $err_or_queue = $self->sqlradius_queue('', 'attr_delete', %opt);
+    return $err_or_queue unless ref $err_or_queue;
+  }
+  # this probably doesn't matter, but just to be safe...
+  my $jobnum = $err_or_queue->jobnum if $action eq 'replace';
+  if ( $action eq 'replace' or $action eq 'insert' ) {
+    my %opt = (
+      attrname  => $new->attrname,
+      attrtype  => $new->attrtype,
+      groupname => $new->radius_group->groupname,
+      op        => $new->op,
+      value     => $new->value,
+    );
+    $err_or_queue = $self->sqlradius_queue('', 'attr_insert', %opt);
+    $err_or_queue->depend_insert($jobnum) if $jobnum;
+    return $err_or_queue unless ref $err_or_queue;
   }
-
-  my $err_or_queue = $self->sqlradius_queue('', "attr_$action",
-    attrnum => $new->attrnum,
-    attrname => $attrname,
-    attrtype => $attrtype,
-    groupname => $groupname,
-  );
-  return $err_or_queue unless ref $err_or_queue;
   '';
 }
 
 sub sqlradius_attr_insert {
   my $dbh = sqlradius_connect(shift, shift, shift);
   my %opt = @_;
-  my $radius_attr = qsearchs('radius_attr', { attrnum => $opt{'attrnum'} })
-    or die 'attrnum '.$opt{'attrnum'}.' not found';
 
   my $table;
   # make sure $table is completely safe
@@ -1027,12 +1030,10 @@ sub sqlradius_attr_insert {
     $table = 'radgroupreply';
   }
   else {
-    die "unknown attribute type '".$radius_attr->attrtype."'";
+    die "unknown attribute type '$opt{attrtype}'";
   }
 
-  my @values = ( 
-    $opt{'groupname'}, map { $radius_attr->$_ } qw(attrname op value)
-  );
+  my @values = @opt{ qw(groupname attrname op value) };
   my $sth = $dbh->prepare(
     'INSERT INTO '.$table.' (groupname, attribute, op, value) VALUES (?,?,?,?)'
   );
@@ -1054,41 +1055,16 @@ sub sqlradius_attr_delete {
     die "unknown attribute type '".$opt{'attrtype'}."'";
   }
 
+  my @values = @opt{ qw(groupname attrname op value) };
   my $sth = $dbh->prepare(
-    'DELETE FROM '.$table.' WHERE groupname = ? AND attribute = ?'
+    'DELETE FROM '.$table.
+    ' WHERE groupname = ? AND attribute = ? AND op = ? AND value = ?'.
+    ' LIMIT 1'
   );
-  $sth->execute( @opt{'groupname', 'attrname'} ) or die $dbh->errstr;
+  $sth->execute(@values) or die $dbh->errstr;
 }
 
-sub sqlradius_attr_replace {
-  my $dbh = sqlradius_connect(shift, shift, shift);
-  my %opt = @_;
-  my $radius_attr = qsearchs('radius_attr', { attrnum => $opt{'attrnum'} })
-    or die 'attrnum '.$opt{'attrnum'}.' not found';
-
-  my $table;
-  if ( $opt{'attrtype'} eq 'C' ) {
-    $table = 'radgroupcheck';
-  }
-  elsif ( $opt{'attrtype'} eq 'R' ) {
-    $table = 'radgroupreply';
-  }
-  else {
-    die "unknown attribute type '".$opt{'attrtype'}."'";
-  }
-
-  my $sth = $dbh->prepare(
-    'UPDATE '.$table.' SET groupname = ?, attribute = ?, op = ?, value = ?
-     WHERE groupname = ? AND attribute = ?'
-  );
-
-  my $new_groupname = $radius_attr->radius_group->groupname;
-  my @new_values = ( 
-    $new_groupname, map { $radius_attr->$_ } qw(attrname op value) 
-  );
-  $sth->execute( @new_values, @opt{'groupname', 'attrname'} )
-    or die $dbh->errstr;
-}
+#sub sqlradius_attr_replace { no longer needed
 
 =item export_group_replace NEW OLD
 
@@ -1185,6 +1161,7 @@ sub import_attrs {
 SELECT groupname, attribute, op, value, \'C\' FROM radgroupcheck
 UNION
 SELECT groupname, attribute, op, value, \'R\' FROM radgroupreply';
+  my @fixes; # things that need to be changed on the radius db
   foreach my $row ( @{ $dbh->selectall_arrayref($sql) } ) {
     my ($groupname, $attrname, $op, $value, $attrtype) = @$row;
     warn "$groupname.$attrname\n";
@@ -1206,6 +1183,20 @@ SELECT groupname, attribute, op, value, \'R\' FROM radgroupreply';
     my $old = $a->{$attrname};
     my $new;
 
+    if ( $attrtype eq 'R' ) {
+      # Freeradius tolerates illegal operators in reply attributes.  We don't.
+      if ( !grep ($_ eq $op, FS::radius_attr->ops('R')) ) {
+        warn "$groupname.$attrname: changing $op to +=\n";
+        # Make a note to change it in the db
+        push @fixes, [
+          'UPDATE radgroupreply SET op = \'+=\' WHERE groupname = ? AND attribute = ? AND op = ? AND VALUE = ?',
+          $groupname, $attrname, $op, $value
+        ];
+        # and import it correctly.
+        $op = '+=';
+      }
+    }
+
     if ( defined $old ) {
       # replace
       $new = new FS::radius_attr {
@@ -1235,6 +1226,13 @@ SELECT groupname, attribute, op, value, \'R\' FROM radgroupreply';
     }
     $attrs_of{$groupname}->{$attrname} = $new;
   } #foreach $row
+
+  foreach (@fixes) {
+    my ($sql, @args) = @$_;
+    my $sth = $dbh->prepare($sql);
+    $sth->execute(@args) or warn $sth->errstr;
+  }
+    
   return;
 }
 
index e5a7151..2af9e8d 100644 (file)
@@ -6,11 +6,16 @@ use FS::part_export::sqlradius;
 
 tie my %options, 'Tie::IxHash', %FS::part_export::sqlradius::options;
 
+$options{'strip_tld'} = { type  => 'checkbox',
+                          label => 'Strip TLD from realm names',
+                        };
+
 %info = (
   'svc'      => 'svc_acct',
   'desc'     => 'Real-time export to SQL-backed RADIUS (FreeRADIUS, ICRADIUS) with realms',
   'options'  => \%options,
   'nodomain' => '',
+  'default_svc_class' => 'Internet',
   'notes' => $FS::part_export::sqlradius::notes1.
              'This export exports domains to RADIUS realms (see also '.
              'sqlradius).  '.
@@ -21,7 +26,11 @@ tie my %options, 'Tie::IxHash', %FS::part_export::sqlradius::options;
 
 sub export_username {
   my($self, $svc_acct) = (shift, shift);
-  $svc_acct->email;
+  my $email = $svc_acct->email;
+  if ( $self->option('strip_tld') ) {
+    $email =~ s/\.\w+$//;
+  }
+  $email;
 }
 
 1;
index 869c7c7..07de875 100644 (file)
@@ -18,6 +18,7 @@ tie my %options, 'Tie::IxHash',
   'desc'    =>
     'Real-time export to a text /etc/raddb/users file (Livingston, Cistron)',
   'options' => \%options,
+  'default_svc_class' => 'Internet',
   'notes'   => <<'END'
 This will edit a text RADIUS users file in place on a remote server.
 Requires installation of
index e7f1126..64d2cc4 100644 (file)
@@ -68,6 +68,7 @@ tie my %options, 'Tie::IxHash', (
   'svc'     => 'svc_broadband',
   'desc'    => 'Sends SNMP SETs to a Trango AP.',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => 'Requires Net::SNMP.  See the documentation for FS::part_export::trango for required virtual fields and usage information.',
 );
 
index 12c3a7f..350a5ad 100644 (file)
@@ -26,6 +26,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_phone',
   'desc'    => 'Provision phone numbers to Vitelity',
   'options' => \%options,
+  'no_machine' => 1,
   'notes'   => <<'END'
 Requires installation of
 <a href="http://search.cpan.org/dist/Net-Vitelity">Net::Vitelity</a>
index 799a8e1..5fca170 100644 (file)
@@ -23,6 +23,7 @@ tie my %options, 'Tie::IxHash',
   'svc'     => 'svc_acct',
   'desc'    => 'Real-time export to vpopmail text files',
   'options' => \%options,
+  'default_svc_class' => 'Email',
   'notes'   => <<'END'
 This export is currently unmaintained.  See shellcommands_withdomain for an
 export that uses vpopmail CLI commands instead.<BR>
index ccf9b3e..a247f05 100644 (file)
@@ -18,10 +18,11 @@ tie my %options, 'Tie::IxHash',
 ;
 
 %info = (
-  'svc'    => 'svc_www',
-  'desc'   => 'Real-time export to Plesk managed hosting service',
-  'options'=> \%options,
-  'notes'  => <<'END'
+  'svc'        => 'svc_www',
+  'desc'       => 'Real-time export to Plesk managed hosting service',
+  'options'    => \%options,
+  'no_machine' => 1,
+  'notes'      => <<'END'
 Real-time export to
 <a href="http://www.swsoft.com/">Plesk</a> managed server.
 Requires installation of
index d6116ab..bef2e94 100644 (file)
@@ -188,3 +188,4 @@ sub ssh_cmd { #subroutine, not method
   '';
 }
 
+1;
diff --git a/FS/FS/part_export_machine.pm b/FS/FS/part_export_machine.pm
new file mode 100644 (file)
index 0000000..1598e03
--- /dev/null
@@ -0,0 +1,155 @@
+package FS::part_export_machine;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( dbh qsearch ); #qsearchs );
+use FS::part_export;
+use FS::svc_export_machine;
+
+=head1 NAME
+
+FS::part_export_machine - Object methods for part_export_machine records
+
+=head1 SYNOPSIS
+
+  use FS::part_export_machine;
+
+  $record = new FS::part_export_machine \%hash;
+  $record = new FS::part_export_machine { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_export_machine object represents an export hostname choice.
+FS::part_export_machine inherits from FS::Record.  The following fields are
+currently supported:
+
+=over 4
+
+=item machinenum
+
+primary key
+
+=item exportnum
+
+Export, see L<FS::part_export>
+
+=item machine
+
+Hostname or IP address
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'part_export_machine'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=cut
+
+sub delete {
+  my $self = shift;
+
+  local $SIG{HUP} = 'IGNORE';
+  local $SIG{INT} = 'IGNORE';
+  local $SIG{QUIT} = 'IGNORE';
+  local $SIG{TERM} = 'IGNORE';
+  local $SIG{TSTP} = 'IGNORE';
+  local $SIG{PIPE} = 'IGNORE';
+  my $oldAutoCommit = $FS::UID::AutoCommit;
+  local $FS::UID::AutoCommit = 0;
+  my $dbh = dbh;
+
+  my $error = $self->SUPER::delete;
+  if ( $error ) {
+    $dbh->rollback if $oldAutoCommit;
+    return $error;
+  }
+
+  foreach my $svc_export_machine ( $self->svc_export_machine ) {
+    my $error = $svc_export_machine->delete;
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
+  $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+  '';
+
+}
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('machinenum')
+    || $self->ut_foreign_key('exportnum', 'part_export', 'exportnum')
+    || $self->ut_domain('machine')
+    || $self->ut_enum('disabled', [ '', 'Y' ])
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item svc_export_machine
+
+=cut
+
+sub svc_export_machine {
+  my $self = shift;
+  qsearch( 'svc_export_machine', { 'machinenum' => $self->machinenum } );
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::part_export>, L<FS::Record>
+
+=cut
+
+1;
+
index 061001b..6e7f8f8 100644 (file)
@@ -103,6 +103,9 @@ inherits from FS::Record.  The following fields are currently supported:
 
 =item fcc_ds0s - Optional DS0 equivalency number for FCC form 477
 
+=item fcc_voip_class - Which column of FCC form 477 part II.B this package 
+belongs in.
+
 =item successor - Foreign key for the part_pkg that replaced this record.
 If this record is not obsolete, will be null.
 
@@ -622,6 +625,7 @@ sub check {
            : $self->ut_agentnum_acl('agentnum', \@null_agentnum_right)
        )
     || $self->ut_numbern('fcc_ds0s')
+    || $self->ut_numbern('fcc_voip_class')
     || $self->ut_foreign_keyn('successor', 'part_pkg', 'pkgpart')
     || $self->ut_foreign_keyn('family_pkgpart', 'part_pkg', 'pkgpart')
     || $self->SUPER::check
@@ -1592,6 +1596,83 @@ sub _upgrade_data { # class method
         }
   }
 
+  # set any package with FCC voice lines to the "VoIP with broadband" category
+  # for backward compatibility
+  #
+  # recover from a bad upgrade bug
+  my $upgrade = 'part_pkg_fcc_voip_class_FIX';
+  if (!FS::upgrade_journal->is_done($upgrade)) {
+    my $bad_upgrade = qsearchs('upgrade_journal', 
+      { upgrade => 'part_pkg_fcc_voip_class' }
+    );
+    if ( $bad_upgrade ) {
+      my $where = 'WHERE history_date <= '.$bad_upgrade->_date.
+                  ' AND  history_date >  '.($bad_upgrade->_date - 3600);
+      my @h_part_pkg_option = map { FS::part_pkg_option->new($_->hashref) }
+        qsearch({
+          'select'    => '*',
+          'table'     => 'h_part_pkg_option',
+          'hashref'   => {},
+          'extra_sql' => "$where AND history_action = 'delete'",
+          'order_by'  => 'ORDER BY history_date ASC',
+        });
+      my @h_pkg_svc = map { FS::pkg_svc->new($_->hashref) }
+        qsearch({
+          'select'    => '*',
+          'table'     => 'h_pkg_svc',
+          'hashref'   => {},
+          'extra_sql' => "$where AND history_action = 'replace_old'",
+          'order_by'  => 'ORDER BY history_date ASC',
+        });
+      my %opt;
+      foreach my $deleted (@h_part_pkg_option, @h_pkg_svc) {
+        my $pkgpart ||= $deleted->pkgpart;
+        $opt{$pkgpart} ||= {
+          options => {},
+          pkg_svc => {},
+          primary_svc => '',
+          hidden_svc => {},
+        };
+        if ( $deleted->isa('FS::part_pkg_option') ) {
+          $opt{$pkgpart}{options}{ $deleted->optionname } = $deleted->optionvalue;
+        } else { # pkg_svc
+          my $svcpart = $deleted->svcpart;
+          $opt{$pkgpart}{pkg_svc}{$svcpart} = $deleted->quantity;
+          $opt{$pkgpart}{hidden_svc}{$svcpart} ||= $deleted->hidden;
+          $opt{$pkgpart}{primary_svc} = $svcpart if $deleted->primary_svc;
+        }
+      }
+      foreach my $pkgpart (keys %opt) {
+        my $part_pkg = FS::part_pkg->by_key($pkgpart);
+        my $error = $part_pkg->replace( $part_pkg->replace_old, $opt{$pkgpart} );
+        if ( $error ) {
+          die "error recovering damaged pkgpart $pkgpart:\n$error\n";
+        }
+      }
+    } # $bad_upgrade exists
+    else { # do the original upgrade, but correctly this time
+      @part_pkg = qsearch('part_pkg', {
+          fcc_ds0s        => { op => '>', value => 0 },
+          fcc_voip_class  => ''
+      });
+      foreach my $part_pkg (@part_pkg) {
+        $part_pkg->set(fcc_voip_class => 2);
+        my @pkg_svc = $part_pkg->pkg_svc;
+        my %quantity = map {$_->svcpart, $_->quantity} @pkg_svc;
+        my %hidden   = map {$_->svcpart, $_->hidden  } @pkg_svc;
+        my $error = $part_pkg->replace(
+          $part_pkg->replace_old,
+          options     => { $part_pkg->options },
+          pkg_svc     => \%quantity,
+          hidden_svc  => \%hidden,
+          primary_svc => ($part_pkg->svcpart || ''),
+        );
+        die $error if $error;
+      }
+    }
+    FS::upgrade_journal->set_done($upgrade);
+  }
+
 }
 
 =item curuser_pkgs_sql
index d28480d..83e543a 100644 (file)
@@ -2,6 +2,7 @@ package FS::part_pkg::delayed_Mixin;
 
 use strict;
 use vars qw(%info);
+use NEXT;
 
 %info = (
   'disabled' => 1,
@@ -45,7 +46,7 @@ sub calc_remain {
                 && $last_bill == $cust_pkg->setup;
   }
 
-  return $self->SUPER::calc_remain($cust_pkg, %options);
+  return $self->NEXT::calc_remain($cust_pkg, %options);
 }
 
 sub can_start_date { ! shift->option('delay_setup', 1) }
index 407343b..50f908c 100644 (file)
@@ -23,7 +23,7 @@ tie my %overlimit_action, 'Tie::IxHash',
   'shortname' => 'Prepaid, no automatic cycle',
   'inherit_fields' => [ 'usage_Mixin', 'global_Mixin' ],
   'fields' => {
-    'recur_action' => { 'name' => 'Action to take upon reaching end of prepaid preiod',
+    'recur_action' => { 'name' => 'Action to take upon reaching end of prepaid period',
                         'type' => 'select',
                        'select_options' => \%recur_action,
                      },
index f8d03dc..ac86f39 100644 (file)
@@ -44,12 +44,16 @@ use FS::part_pkg::flat;
 
 sub calc_recur {
   my $self = shift;
-  return $self->calc_prorate(@_, $self->cutoff_day) - $self->calc_discount(@_);
+  my $cust_pkg = $_[0];
+  $self->calc_prorate(@_, $self->cutoff_day($cust_pkg))
+    - $self->calc_discount(@_);
 }
 
 sub cutoff_day {
-  my $self = shift;
-  split(/\s*,\s*/, $self->option('cutoff_day', 1) || '1');
+  my( $self, $cust_pkg ) = @_;
+  my $prorate_day = $cust_pkg->cust_main->prorate_day;
+  $prorate_day ? ( $prorate_day )
+               : split(/\s*,\s*/, $self->option('cutoff_day', 1) || '1');
 }
 
 1;
index 9d7341b..03d5c2c 100644 (file)
@@ -39,14 +39,15 @@ sub calc_setup {
 
 sub cutoff_day {
   # prorate/subscription only; we don't support sync_bill_date here
-  my $self = shift;
-  my $cust_pkg = shift;
+  my( $self, $cust_pkg ) = @_;
   my $recur_method = $self->option('recur_method',1) || 'anniversary';
-  if ( $recur_method eq 'prorate' or $recur_method eq 'subscription' ) {
-    return $self->option('cutoff_day',1) || 1;
-  } else {
-    return ();
-  }
+  return () unless $recur_method eq 'prorate'
+                || $recur_method eq 'subscription';
+
+  #false laziness w/prorate.pm::cutoff_day
+  my $prorate_day = $cust_pkg->cust_main->prorate_day;
+  $prorate_day ? ( $prorate_day )
+               : split(/\s*,\s*/, $self->option('cutoff_day', 1) || '1');
 }
 
 sub calc_recur_Common {
index e29c3d0..c83f700 100644 (file)
@@ -384,7 +384,7 @@ sub batch_import {
     }
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
-      return "Unexpected trailing columns in line (wrong format?): $line";
+      return "Unexpected trailing columns in line (wrong format?) importing part_pkg_taxrate: $line";
     }
 
     my $error = &{$hook}(\%part_pkg_taxrate);
index dd18e87..7f22411 100644 (file)
@@ -591,7 +591,7 @@ sub _svc_defs {
       };
       my $mod = $1;
 
-      if ( $mod =~ /^svc_[A-Z]/ or $mod =~ /^svc_acct_pop$/ ) {
+      if ( $mod =~ /^svc_[A-Z]/ or $mod =~ /^(svc_acct_pop|svc_export_machine)$/ ) {
         warn "skipping FS::$mod" if $DEBUG;
        next;
       }
index 813d096..b8da9b4 100644 (file)
@@ -807,8 +807,8 @@ sub try_to_resolve {
     }
   );
 
-  if ( @unresolved ) {
-    my $days = $conf->config('batch-auto_resolve_days') || '';
+  if ( @unresolved and $conf->exists('batch-auto_resolve_days') ) {
+    my $days = $conf->config('batch-auto_resolve_days'); # can be zero
     # either 'approve' or 'decline'
     my $action = $conf->config('batch-auto_resolve_status') || '';
     return unless 
@@ -861,6 +861,9 @@ sub prepare_for_export {
     return "error updating pay_batch status: $error\n" if $error;
   } elsif ($status eq 'I' && $curuser->access_right('Reprocess batches')) {
     $first_download = 0;
+  } elsif ($status eq 'R' && 
+           $curuser->access_right('Redownload resolved batches')) {
+    $first_download = 0;
   } else {
     die "No pending batch.\n";
   }
@@ -1080,7 +1083,7 @@ sub _upgrade_data {
   for my $format (keys %export_info) {
     my $mod = "FS::pay_batch::$format";
     if ( $mod->can('_upgrade_gateway') 
-        and length( $conf->config("batchconfig-$format") ) ) {
+        and $conf->exists("batchconfig-$format") ) {
 
       local $@;
       my ($module, %gw_options) = $mod->_upgrade_gateway;
@@ -1109,7 +1112,7 @@ sub _upgrade_data {
 
         # and if appropriate, make it the system default
         for my $payby (qw(CARD CHEK)) {
-          if ( $conf->config("batch-fixed_format-$payby") eq $format ) {
+          if ( ($conf->config("batch-fixed_format-$payby") || '') eq $format ) {
             warn "Setting as default for $payby.\n";
             $conf->set("batch-gateway-$payby", $gateway->gatewaynum);
             $conf->delete("batch-fixed_format-$payby");
index 7bfc22a..719b504 100644 (file)
@@ -31,13 +31,13 @@ $name = 'BoM';
   },
   header => sub { 
     my $pay_batch = shift;
-    sprintf( "A%10s%04u%06u%05u%54s\n", 
+    sprintf( "A%10s%04u%06u%05u%54s\n",  #80
       $origid,
       $pay_batch->batchnum,
       jdate($pay_batch->download),
       $datacenter,
       "") .
-    sprintf( "XD%03u%06u%-15s%-30s%09u%-12s   \n",
+    sprintf( "XD%03u%06u%-15s%-30s%09u%-12s   \n", #80
       $typecode,
       jdate($pay_batch->download),
       $shortname,
@@ -48,7 +48,7 @@ $name = 'BoM';
   row => sub {
     my ($cust_pay_batch, $pay_batch) = @_;
     my ($account, $aba) = split('@', $cust_pay_batch->payinfo);
-    sprintf( "D%010.0f%09u%-12s%-29s%-19s\n",
+    sprintf( "D%010.0f%09u%-12s%-29s%-19s\n", #80
       $cust_pay_batch->amount * 100,
       $aba,
       $account,
@@ -58,8 +58,8 @@ $name = 'BoM';
   },
   footer => sub {
     my ($pay_batch, $batchcount, $batchtotal) = @_;
-    sprintf( "YD%08u%014.0f%56s\n", $batchcount, $batchtotal*100, "").
-    sprintf( "Z%014u%04u%014u%05u%41s\n", 
+    sprintf( "YD%08u%014.0f%56s\n", $batchcount, $batchtotal*100, ""). #80
+    sprintf( "Z%014u%04u%014u%05u%42s\n",  #80 now
       $batchtotal*100, $batchcount, "0", "0", "");
   },
 );
index 3a6befe..93612f1 100644 (file)
@@ -154,5 +154,14 @@ $name = 'td_eft1464';
   },
 );
 
+sub _upgrade_gateway {
+  my $conf = FS::Conf->new;
+  my @batchconfig = $conf->config('batchconfig-td_eft1464');
+  my %options;
+  @options{ qw(originator datacentre short_name long_name return_branch 
+               return_account cpa_code) } = @batchconfig;
+  ( 'TD_EFT', %options );
+}
+
 1;
 
index ccaa1c3..bf2711b 100644 (file)
@@ -142,13 +142,39 @@ sub cust_main {
 
 =cut
 
-sub cust_bill_pkg {
+sub cust_bill_pkg { #actually quotation_pkg objects
   my $self = shift;
-  #actually quotation_pkg objects
   qsearch('quotation_pkg', { quotationnum=>$self->quotationnum });
 }
 
-=back
+=item total_setup
+
+=cut
+
+sub total_setup {
+  my $self = shift;
+  $self->_total('setup');
+}
+
+=item total_recur [ FREQ ]
+
+=cut
+
+sub total_recur {
+  my $self = shift;
+#=item total_recur [ FREQ ]
+  #my $freq = @_ ? shift : '';
+  $self->_total('recur');
+}
+
+sub _total {
+  my( $self, $method ) = @_;
+
+  my $total = 0;
+  $total += $_->$method() for $self->cust_bill_pkg;
+  sprintf('%.2f', $total);
+
+}
 
 =item enable_previous
 
@@ -156,6 +182,130 @@ sub cust_bill_pkg {
 
 sub enable_previous { 0 }
 
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+
+=item search_sql_where HASHREF
+
+Class method which returns an SQL WHERE fragment to search for parameters
+specified in HASHREF.  Valid parameters are
+
+=over 4
+
+=item _date
+
+List reference of start date, end date, as UNIX timestamps.
+
+=item invnum_min
+
+=item invnum_max
+
+=item agentnum
+
+=item charged
+
+List reference of charged limits (exclusive).
+
+=item owed
+
+List reference of charged limits (exclusive).
+
+=item open
+
+flag, return open invoices only
+
+=item net
+
+flag, return net invoices only
+
+=item days
+
+=item newest_percust
+
+=back
+
+Note: validates all passed-in data; i.e. safe to use with unchecked CGI params.
+
+=cut
+
+sub search_sql_where {
+  my($class, $param) = @_;
+  #if ( $DEBUG ) {
+  #  warn "$me search_sql_where called with params: \n".
+  #       join("\n", map { "  $_: ". $param->{$_} } keys %$param ). "\n";
+  #}
+
+  my @search = ();
+
+  #agentnum
+  if ( $param->{'agentnum'} =~ /^(\d+)$/ ) {
+    push @search, "( prospect_main.agentnum = $1 OR cust_main.agentnum = $1 )";
+  }
+
+#  #refnum
+#  if ( $param->{'refnum'} =~ /^(\d+)$/ ) {
+#    push @search, "cust_main.refnum = $1";
+#  }
+
+  #prospectnum
+  if ( $param->{'prospectnum'} =~ /^(\d+)$/ ) {
+    push @search, "quotation.prospectnum = $1";
+  }
+
+  #custnum
+  if ( $param->{'custnum'} =~ /^(\d+)$/ ) {
+    push @search, "cust_bill.custnum = $1";
+  }
+
+  #_date
+  if ( $param->{_date} ) {
+    my($beginning, $ending) = @{$param->{_date}};
+
+    push @search, "quotation._date >= $beginning",
+                  "quotation._date <  $ending";
+  }
+
+  #quotationnum
+  if ( $param->{'quotationnum_min'} =~ /^(\d+)$/ ) {
+    push @search, "quotation.quotationnum >= $1";
+  }
+  if ( $param->{'quotationnum_max'} =~ /^(\d+)$/ ) {
+    push @search, "quotation.quotationnum <= $1";
+  }
+
+#  #charged
+#  if ( $param->{charged} ) {
+#    my @charged = ref($param->{charged})
+#                    ? @{ $param->{charged} }
+#                    : ($param->{charged});
+#
+#    push @search, map { s/^charged/cust_bill.charged/; $_; }
+#                      @charged;
+#  }
+
+  my $owed_sql = FS::cust_bill->owed_sql;
+
+  #days
+  push @search, "quotation._date < ". (time-86400*$param->{'days'})
+    if $param->{'days'};
+
+  #agent virtualization
+  my $curuser = $FS::CurrentUser::CurrentUser;
+  #false laziness w/search/quotation.html
+  push @search,' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
+               '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
+               ' )    ';
+
+  join(' AND ', @search );
+
+}
+
+=back
+
 =head1 BUGS
 
 =head1 SEE ALSO
index 37aa0f3..f1a4efe 100644 (file)
@@ -47,6 +47,8 @@ description
 
 priority - for export
 
+=item speed_up, speed_down - connection speeds in bits per second.  Some 
+exports may use this to generate appropriate RADIUS attributes.
 
 =back
 
@@ -176,6 +178,8 @@ sub check {
     || $self->ut_text('groupname')
     || $self->ut_textn('description')
     || $self->ut_numbern('priority')
+    || $self->ut_numbern('speed_up')
+    || $self->ut_numbern('speed_down')
   ;
   return $error if $error;
 
index 02d8250..a2511cf 100644 (file)
@@ -387,7 +387,7 @@ sub rate_detail {
 
 =item process
 
-Experimental job-queue processor for web interface adds/edits
+Job-queue processor for web interface adds/edits
 
 =cut
 
index 377da49..a9a7d74 100644 (file)
@@ -46,6 +46,15 @@ FS::Record.  The following fields are currently supported:
 
 =item disabled - 'Y' or ''
 
+=item unsuspend_pkgpart - for suspension reasons only, the pkgpart (see
+L<FS::part_pkg>) of a package to be ordered when the package is unsuspended.
+Typically this will be some kind of reactivation fee.  Attaching it to 
+a suspension reason allows the reactivation fee to be charged for some
+suspensions but not others.
+
+=item unsuspend_hold - 'Y' or ''.  If unsuspend_pkgpart is set, this tells
+whether to bill the unsuspend package immediately ('') or to wait until 
+the customer's next invoice ('Y').
 
 =back
 
@@ -97,16 +106,30 @@ sub check {
 
   my $error = 
     $self->ut_numbern('reasonnum')
+    || $self->ut_number('reason_type')
+    || $self->ut_foreign_key('reason_type', 'reason_type', 'typenum')
     || $self->ut_text('reason')
+    || $self->ut_flag('disabled')
   ;
   return $error if $error;
 
+  if ( $self->reasontype->class eq 'S' ) {
+    $error = $self->ut_numbern('unsuspend_pkgpart')
+          || $self->ut_foreign_keyn('unsuspend_pkgpart', 'part_pkg', 'pkgpart')
+          || $self->ut_flag('unsuspend_hold')
+    ;
+    return $error if $error;
+  } else {
+    $self->set('unsuspend_pkgpart' => '');
+    $self->set('unsuspend_hold'    => '');
+  }
+
   $self->SUPER::check;
 }
 
 =item reasontype
 
-Returns the reason_type (see <I>FS::reason_type</I>) associated with this reason.
+Returns the reason_type (see L<FS::reason_type>) associated with this reason.
 
 =cut
 
@@ -118,7 +141,7 @@ sub reasontype {
 
 =head1 BUGS
 
-Here be termintes.  Don't use on wooden computers.
+Here by termintes.  Don't use on wooden computers.
 
 =head1 SEE ALSO
 
index a6daf44..7aede54 100644 (file)
@@ -200,12 +200,13 @@ I<depend_jobnum>.
 If I<jobnum> is set to an array reference, the jobnums of any export jobs will
 be added to the referenced array.
 
-If I<child_objects> is set to an array reference of FS::tablename objects (for
-example, FS::acct_snarf objects), they will have their svcnum field set and
-will be inserted after this record, but before any exports are run.  Each
-element of the array can also optionally be a two-element array reference
-containing the child object and the name of an alternate field to be filled in
-with the newly-inserted svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
+If I<child_objects> is set to an array reference of FS::tablename objects
+(for example, FS::svc_export_machine or FS::acct_snarf objects), they
+will have their svcnum field set and will be inserted after this record,
+but before any exports are run.  Each element of the array can also
+optionally be a two-element array reference containing the child object
+and the name of an alternate field to be filled in with the newly-inserted
+svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
 
 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
 jobnums), all provisioning jobs will have a dependancy on the supplied
@@ -439,7 +440,16 @@ sub expire {
 Replaces OLD_RECORD with this one.  If there is an error, returns the error,
 otherwise returns false.
 
-Currently available options are: I<export_args> and I<depend_jobnum>.
+Currently available options are: I<child_objects>, I<export_args> and
+I<depend_jobnum>.
+
+If I<child_objects> is set to an array reference of FS::tablename objects
+(for example, FS::svc_export_machine or FS::acct_snarf objects), they
+will have their svcnum field set and will be inserted or replaced after
+this record, but before any exports are run.  Each element of the array
+can also optionally be a two-element array reference containing the
+child object and the name of an alternate field to be filled in with
+the newly-inserted svcnum, for example C<[ $svc_forward, 'srcsvc' ]>
 
 If I<depend_jobnum> is set (to a scalar jobnum or an array reference of
 jobnums), all provisioning jobs will have a dependancy on the supplied
@@ -462,6 +472,8 @@ sub replace {
       ? shift
       : { @_ };
 
+  my $objects = $options->{'child_objects'} || [];
+
   my @jobnums = ();
   local $FS::queue::jobnums = \@jobnums;
   warn "[$me] replace: set \$FS::queue::jobnums to $FS::queue::jobnums\n"
@@ -511,6 +523,34 @@ sub replace {
     return $error;
   }
 
+  foreach my $object ( @$objects ) {
+    my($field, $obj);
+    if ( ref($object) eq 'ARRAY' ) {
+      ($obj, $field) = @$object;
+    } else {
+      $obj = $object;
+      $field = 'svcnum';
+    }
+    $obj->$field($new->svcnum);
+
+    my $oldobj = qsearchs( $obj->table, {
+                             $field => $new->svcnum,
+                             map { $_ => $obj->$_ } $obj->_svc_child_partfields,
+                         });
+
+    if ( $oldobj ) {
+      my $pkey = $oldobj->primary_key;
+      $obj->$pkey($oldobj->$pkey);
+      $obj->replace($oldobj);
+    } else {
+      $error = $obj->insert;
+    }
+    if ( $error ) {
+      $dbh->rollback if $oldAutoCommit;
+      return $error;
+    }
+  }
+
   #new-style exports!
   unless ( $noexport_hack ) {
 
index 0b55884..6adbc6f 100644 (file)
@@ -52,5 +52,4 @@ sub tower_sector_sql {
   @where;
 }
 
-
 1;
index e67db43..7ce79ae 100644 (file)
@@ -2808,6 +2808,13 @@ Arrayref of additional WHERE clauses, will be ANDed together.
 sub search {
   my ($class, $params) = @_;
 
+  my @from = (
+    ' LEFT JOIN cust_svc  USING ( svcnum  ) ',
+    ' LEFT JOIN part_svc  USING ( svcpart ) ',
+    ' LEFT JOIN cust_pkg  USING ( pkgnum  ) ',
+    ' LEFT JOIN cust_main USING ( custnum ) ',
+  );
+
   my @where = ();
 
   # domain
@@ -2852,9 +2859,17 @@ sub search {
     push @where, "svcpart = $1";
   }
 
+  if ( $params->{'exportnum'} =~ /^(\d+)$/ ) {
+    push @from, ' LEFT JOIN export_svc USING ( svcpart )';
+    push @where, "exportnum = $1";
+  }
+
   # sector and tower
   my @where_sector = $class->tower_sector_sql($params);
-  push @where, @where_sector if @where_sector;
+  if ( @where_sector ) {
+    push @where, @where_sector;
+    push @from, ' LEFT JOIN tower_sector USING ( sectornum )';
+  }
 
   # here is the agent virtualization
   #if ($params->{CurrentUser}) {
@@ -2875,16 +2890,9 @@ sub search {
 
   push @where, @{ $params->{'where'} } if $params->{'where'};
 
+  my $addl_from = join(' ', @from);
   my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
 
-  my $addl_from = ' LEFT JOIN cust_svc  USING ( svcnum  ) '.
-                  ' LEFT JOIN part_svc  USING ( svcpart ) '.
-                  ' LEFT JOIN cust_pkg  USING ( pkgnum  ) '.
-                  ' LEFT JOIN cust_main USING ( custnum ) ';
-
-  $addl_from .= ' LEFT JOIN tower_sector USING ( sectornum )'
-    if @where_sector;
-
   my $count_query = "SELECT COUNT(*) FROM svc_acct $addl_from $extra_sql";
   #if ( keys %svc_acct ) {
   #  $count_query .= ' WHERE '.
index 8210269..26659d5 100755 (executable)
@@ -245,6 +245,12 @@ sub search {
     push @where, "svcpart = $1";
   }
 
+  #exportnum
+  if ( $params->{'exportnum'} =~ /^(\d+)$/ ) {
+    push @from, 'LEFT JOIN export_svc USING ( svcpart )';
+    push @where, "exportnum = $1";
+  }
+
   #ip_addr
   if ( $params->{'ip_addr'} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ ) {
     push @where, "ip_addr = '$1'";
diff --git a/FS/FS/svc_export_machine.pm b/FS/FS/svc_export_machine.pm
new file mode 100644 (file)
index 0000000..10f7b68
--- /dev/null
@@ -0,0 +1,124 @@
+package FS::svc_export_machine;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearchs ); #qsearch );
+use FS::cust_svc;
+use FS::part_export;
+use FS::part_export_machine;
+
+sub _svc_child_partfields { ('exportnum') };
+
+=head1 NAME
+
+FS::svc_export_machine - Object methods for svc_export_machine records
+
+=head1 SYNOPSIS
+
+  use FS::svc_export_machine;
+
+  $record = new FS::svc_export_machine \%hash;
+  $record = new FS::svc_export_machine { 'column' => 'value' };
+
+  $error = $record->insert;
+
+  $error = $new_record->replace($old_record);
+
+  $error = $record->delete;
+
+  $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::svc_export_machine object represents a customer service export
+hostname.  FS::svc_export_machine inherits from FS::Record.  The following
+fields are currently supported:
+
+=over 4
+
+=item svcexportmachinenum
+
+primary key
+
+=item svcnum
+
+Customer service, see L<FS::cust_svc>
+
+=item machinenum
+
+Export hostname, see L<FS::part_export_machine>
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new record.  To add the record to the database, see L<"insert">.
+
+Note that this stores the hash reference, not a distinct copy of the hash it
+points to.  You can ask the object for a copy with the I<hash> method.
+
+=cut
+
+sub table { 'svc_export_machine'; }
+
+=item insert
+
+Adds this record to the database.  If there is an error, returns the error,
+otherwise returns false.
+
+=item delete
+
+Delete this record from the database.
+
+=item replace OLD_RECORD
+
+Replaces the OLD_RECORD with this one in the database.  If there is an error,
+returns the error, otherwise returns false.
+
+=item check
+
+Checks all fields to make sure this is a valid record.  If there is
+an error, returns the error, otherwise returns false.  Called by the insert
+and replace methods.
+
+=cut
+
+sub check {
+  my $self = shift;
+
+  my $error = 
+    $self->ut_numbern('svcexportmachinenum')
+    || $self->ut_foreign_key('svcnum',     'cust_svc',            'svcnum'    )
+    || $self->ut_foreign_key('exportnum',  'part_export',         'exportnum' )
+    || $self->ut_foreign_key('machinenum', 'part_export_machine', 'machinenum')
+  ;
+  return $error if $error;
+
+  $self->SUPER::check;
+}
+
+=item part_export_machine
+
+=cut
+
+sub part_export_machine {
+  my $self = shift;
+  qsearchs('part_export_machine', { 'machinenum' => $self->machinenum } );
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_svc>, L<FS::part_export_machine>, L<FS::Record>
+
+=cut
+
+1;
+
index 4f03969..bfec2c0 100644 (file)
@@ -339,7 +339,7 @@ sub batch_import {
     }
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
-      return "Unexpected trailing columns in line (wrong format?): $line";
+      return "Unexpected trailing columns in line (wrong format?) importing tax_class: $line";
     }
 
     my $error = &{$hook}(\%tax_class);
index e9496e4..a5a623d 100644 (file)
@@ -10,6 +10,7 @@ use DateTime::Format::Strptime;
 use Storable qw( thaw nfreeze );
 use IO::File;
 use File::Temp;
+use Text::CSV_XS;
 use LWP::UserAgent;
 use HTTP::Request;
 use HTTP::Response;
@@ -637,6 +638,7 @@ sub batch_import {
   $count *=2;
 
   if ( $format eq 'cch' || $format eq 'cch-update' ) {
+    #false laziness w/below (sub _perform_cch_diff)
     @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
                   excessrate effective_date taxauth taxtype taxcat taxname
                   usetax useexcessrate fee unittype feemax maxtype passflag
@@ -715,9 +717,6 @@ sub batch_import {
     die "unknown format $format";
   }
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
-
   my $csv = new Text::CSV_XS;
 
   my $imported = 0;
@@ -758,9 +757,10 @@ sub batch_import {
     foreach my $field ( @fields ) {
       $tax_rate{$field} = shift @columns; 
     }
+
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
-      return "Unexpected trailing columns in line (wrong format?): $line";
+      return "Unexpected trailing columns in line (wrong format?) importing tax_rate: $line";
     }
 
     my $error = &{$hook}(\%tax_rate);
@@ -1115,8 +1115,26 @@ sub _perform_cch_diff {
   }
   close $newcsvfh;
 
-  for (keys %oldlines) {
-    print $dfh $_, ',"D"', "\n" if $oldlines{$_};
+  #false laziness w/above (sub batch_import)
+  my @fields = qw( geocode inoutcity inoutlocal tax location taxbase taxmax
+                   excessrate effective_date taxauth taxtype taxcat taxname
+                   usetax useexcessrate fee unittype feemax maxtype passflag
+                   passtype basetype );
+  my $numfields = scalar(@fields);
+
+  my $csv = new Text::CSV_XS { 'always_quote' => 1 };
+
+  for my $line (grep $oldlines{$_}, keys %oldlines) {
+
+    $csv->parse($line) or do {
+      #$dbh->rollback if $oldAutoCommit;
+      die "can't parse: ". $csv->error_input();
+    };
+    my @columns = $csv->fields();
+    
+    $csv->combine( splice(@columns, 0, $numfields) );
+
+    print $dfh $csv->string, ',"D"', "\n";
   }
 
   close $dfh;
@@ -1170,9 +1188,6 @@ sub _cch_fetch_and_unzip {
 sub _cch_extract_csv_from_dbf {
   my ( $job, $dir, $name ) = @_;
 
-  eval "use Text::CSV_XS;";
-  die $@ if $@;
-
   eval "use XBase;";
   die $@ if $@;
 
@@ -1635,16 +1650,16 @@ sub process_download_and_update {
 
     if (-d $dir) {
 
-      if (-d "$dir.4") {
-        opendir(my $dirh, "$dir.4") or die "failed to open $dir.4: $!\n";
+      if (-d "$dir.9") {
+        opendir(my $dirh, "$dir.9") or die "failed to open $dir.9: $!\n";
         foreach my $file (readdir($dirh)) {
-          unlink "$dir.4/$file" if (-f "$dir.4/$file");
+          unlink "$dir.9/$file" if (-f "$dir.9/$file");
         }
         closedir($dirh);
-        rmdir "$dir.4";
+        rmdir "$dir.9";
       }
 
-      for (3, 2, 1) {
+      for (8, 7, 6, 5, 4, 3, 2, 1) {
         if ( -e "$dir.$_" ) {
           rename "$dir.$_", "$dir.". ($_+1) or die "can't rename $dir.$_: $!\n";
         }
index 1a6c47d..b4be8b9 100644 (file)
@@ -301,7 +301,7 @@ sub batch_import {
     }
     if ( scalar( @columns ) ) {
       $dbh->rollback if $oldAutoCommit;
-      return "Unexpected trailing columns in line (wrong format?): $line";
+      return "Unexpected trailing columns in line (wrong format?) importing tax-rate_location: $line";
     }
 
     my $error = &{$hook}(\%tax_rate_location);
index 590874d..b5ee87e 100644 (file)
@@ -94,6 +94,7 @@ FS/h_cust_pkg_reason.pm
 FS/h_cust_svc.pm
 FS/h_cust_tax_exempt.pm
 FS/h_domain_record.pm
+FS/h_part_pkg.pm
 FS/h_svc_acct.pm
 FS/h_svc_broadband.pm
 FS/h_svc_domain.pm
@@ -649,3 +650,26 @@ FS/quotation_pkg_discount.pm
 t/quotation_pkg_discount.t
 FS/Quotable_Mixin.pm
 t/Quotable_Mixin.t
+FS/cust_bill_void.pm
+t/cust_bill_void.t
+FS/cust_bill_pkg_void.pm
+t/cust_bill_pkg_void.t
+FS/cust_bill_pkg_detail_void.pm
+t/cust_bill_pkg_detail_void.t
+FS/cust_bill_pkg_display_void.pm
+t/cust_bill_pkg_display_void.t
+FS/cust_bill_pkg_tax_location_void.pm
+t/cust_bill_pkg_tax_location_void.t
+FS/cust_bill_pkg_tax_rate_location_void.pm
+t/cust_bill_pkg_tax_rate_location_void.t
+FS/cust_tax_exempt_pkg_void.pm
+t/cust_tax_exempt_pkg_void.t
+FS/cust_bill_pkg_discount_void.pm
+t/cust_bill_pkg_discount_void.t
+FS/Trace.pm
+FS/agent_pkg_class.pm
+t/agent_pkg_class.t
+FS/part_export_machine.pm
+t/part_export_machine.t
+FS/svc_export_machine.pm
+t/svc_export_machine.t
index 2cf75f3..b21bd5b 100644 (file)
@@ -108,7 +108,7 @@ while (1) {
   }
 
   myexit() if sigterm() || sigint();
-  sleep 1 unless $found;
+  sleep 5 unless $found;
 
 }
 
index 2b33d16..8e8ae4f 100755 (executable)
@@ -7,7 +7,7 @@ use FS::Conf;
 
 &untaint_argv; #what it sounds like  (eww)
 use vars qw(%opt);
-getopts("p:a:d:vl:sy:nmrkg:uo", \%opt);
+getopts("p:a:d:vl:sy:nmrkg:o", \%opt);
 
 my $user = shift or die &usage;
 adminsuidsetup $user;
@@ -51,16 +51,6 @@ unless ( $opt{k} ) {
   notify_flat_delay(%opt);
 }
 
-#debian Pg 8.1+ auto-vaccums, 7.4 w/postgresql-contrib
-if ( $opt{u} ) {
-  use FS::Cron::vacuum qw(vacuum);
-  vacuum();
-}
-
-#you can skip this just by not having the config
-use FS::Cron::backup qw(backup);
-backup();
-
 #same
 use FS::Cron::rt_tasks qw(rt_daily);
 rt_daily(%opt);
@@ -70,11 +60,20 @@ use FS::Cron::pay_batch qw(batch_submit batch_receive);
 batch_submit(%opt);
 batch_receive(%opt);
 
+#you can skip this by not having the config
+use FS::Cron::agent_email qw(agent_email);
+agent_email(%opt);
+
 my $deldir = "$FS::UID::cache_dir/cache.$FS::UID::datasrc/";
 unlink <${deldir}.invoice*>;
 unlink <${deldir}.letter*>;
 unlink <${deldir}.CGItemp*>;
 
+#backup should be last
+#you can skip this just by not having the config
+use FS::Cron::backup qw(backup);
+backup();
+
 ###
 # subroutines
 ###
@@ -145,8 +144,6 @@ the bill and collect methods of a cust_main object.  See L<FS::cust_main>.
 
   -k: skip notify_flat_delay
 
-  -u: Do a vacuum (starting with version 1.9, this is not run by default).
-
 user: From the mapsecrets file - see config.html from the base documentation
 
 custnum: if one or more customer numbers are specified, only bills those
diff --git a/FS/t/agent_pkg_class.t b/FS/t/agent_pkg_class.t
new file mode 100644 (file)
index 0000000..dc0fa12
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::agent_pkg_class;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_detail_void.t b/FS/t/cust_bill_pkg_detail_void.t
new file mode 100644 (file)
index 0000000..bd58c4e
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_detail_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_discount_void.t b/FS/t/cust_bill_pkg_discount_void.t
new file mode 100644 (file)
index 0000000..e591eb0
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_discount_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_display_void.t b/FS/t/cust_bill_pkg_display_void.t
new file mode 100644 (file)
index 0000000..87403e1
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_display_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_tax_location_void.t b/FS/t/cust_bill_pkg_tax_location_void.t
new file mode 100644 (file)
index 0000000..dbfea51
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_tax_location_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_tax_rate_location_void.t b/FS/t/cust_bill_pkg_tax_rate_location_void.t
new file mode 100644 (file)
index 0000000..8ebda65
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_tax_rate_location_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_pkg_void.t b/FS/t/cust_bill_pkg_void.t
new file mode 100644 (file)
index 0000000..9256b46
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_pkg_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_bill_void.t b/FS/t/cust_bill_void.t
new file mode 100644 (file)
index 0000000..95ff4a4
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_bill_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/cust_tax_exempt_pkg_void.t b/FS/t/cust_tax_exempt_pkg_void.t
new file mode 100644 (file)
index 0000000..42d8620
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::cust_tax_exempt_pkg_void;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/part_export_machine.t b/FS/t/part_export_machine.t
new file mode 100644 (file)
index 0000000..792bb50
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_export_machine;
+$loaded=1;
+print "ok 1\n";
diff --git a/FS/t/svc_export_machine.t b/FS/t/svc_export_machine.t
new file mode 100644 (file)
index 0000000..5279be2
--- /dev/null
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::svc_export_machine;
+$loaded=1;
+print "ok 1\n";
index 95ffbf2..10c06eb 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -67,6 +67,7 @@ HTTPD_RESTART = /etc/init.d/apache2 restart
 #(an include directory, not a file, "Include /etc/apache/conf.d" in httpd.conf)
 #deb (3.1+), apache2
 APACHE_CONF = /etc/apache2/conf.d
+INSSERV_OVERRIDE = /etc/insserv/overrides
 
 FREESIDE_RESTART = ${INIT_FILE} restart
 
@@ -168,6 +169,12 @@ install-docs: check-conflicts docs
        cp -r masondocs ${FREESIDE_DOCUMENT_ROOT}
        chown -R freeside:freeside ${FREESIDE_DOCUMENT_ROOT}
        cp htetc/handler.pl ${MASON_HANDLER}
+       perl -p -i -e "\
+         s|%%%FREESIDE_EXPORT%%%|${FREESIDE_EXPORT}|g;\
+         s'%%%RT_ENABLED%%%'${RT_ENABLED}'g; \
+       " ${MASON_HANDLER} || true
+       mkdir -p ${FREESIDE_EXPORT}/profile
+       chown freeside ${FREESIDE_EXPORT}/profile
        cp htetc/htpasswd.logout ${FREESIDE_CONF}
        [ ! -e ${MASONDATA} ] && mkdir ${MASONDATA} || true
        chown -R freeside ${MASONDATA}
@@ -179,6 +186,8 @@ dev-docs:
        perl -p -i -e "\
          s'###use Module::Refresh;###'use Module::Refresh;'; \
          s'###Module::Refresh->refresh;###'Module::Refresh->refresh;'; \
+         s|%%%FREESIDE_EXPORT%%%|${FREESIDE_EXPORT}|g;\
+         s'%%%RT_ENABLED%%%'${RT_ENABLED}'g; \
        " ${MASON_HANDLER} || true
 
 perl-modules:
@@ -238,9 +247,9 @@ dev-perl-modules: perl-modules
        ln -sf ${FREESIDE_PATH}/FS/blib/lib/FS ${PERL_INC_DEV_KLUDGE}/FS
 
 install-texmf: 
-       install -D -o freeside -m 444 etc/fslongtable.sty \
-          `kpsewhich -expand-var \\\$$TEXMFLOCAL`/tex/generic/fslongtable.sty
-       texhash `kpsewhich -expand-var \\\$$TEXMFLOCAL`
+       install -D -o freeside -m 444 etc/longtable.sty \
+         ~freeside/texmf/tex/longtable.sty
+       texhash ~freeside
 
 install-init:
        #[ -e ${INIT_FILE} ] || install -o root -g ${INSTALLGROUP} -m 711 init.d/freeside-init ${INIT_FILE}
@@ -264,6 +273,7 @@ install-apache:
              s'%%%MASON_HANDLER%%%'${MASON_HANDLER}'g; \
            " ${APACHE_CONF}/freeside-*.conf \
          ) || true
+       [ -d ${INSSERV_OVERRIDE} ] && [ -x /sbin/insserv ] && ( install -o root -m 755 init.d/insserv-override-apache2 ${INSSERV_OVERRIDE}/apache2 && insserv -d ) || true
 
 install-selfservice:
        [ -e ~freeside ] || cp -pr /etc/skel ~freeside && chown -R freeside ~freeside
index ca28ede..6d09863 100755 (executable)
@@ -20,8 +20,8 @@ die "no files!" unless @ARGV;
 system join('',
   "( cd /home/$USER/freeside2.3/$prefix; git pull ) && ",
   "( cd /home/$USER/freeside2.1/$prefix; git pull ) && ",
-  "git diff -u @ARGV | ( cd /home/$USER/freeside2.3/$prefix; patch ) ",
-  " && git diff -u @ARGV | ( cd /home/$USER/freeside2.1/$prefix; patch ) ",
+  "git diff -u @ARGV | ( cd /home/$USER/freeside2.3/$prefix; patch -p1 ) ",
+  " && git diff -u @ARGV | ( cd /home/$USER/freeside2.1/$prefix; patch -p1 ) ",
   " && ( ( git commit  -m $desc @ARGV && git push); ",
   "( cd /home/$USER/freeside2.3/$prefix; git commit -m $desc @ARGV && git push); ",
   "( cd /home/$USER/freeside2.1/$prefix; git commit -m $desc @ARGV && git push) )"
index 0c0575a..d38c848 100755 (executable)
@@ -3,7 +3,8 @@
 my $file = shift;
 
 chomp(my $dir = `pwd`);
-$dir =~ s/freeside\//freeside2.3\//;
+$dir =~ s/freeside(\/?)/freeside2.3$1/;
+warn $dir;
 
 #$cmd = "diff -u $file $dir/$file";
 $cmd = "diff -u $dir/$file $file";
diff --git a/bin/agent_email b/bin/agent_email
new file mode 100755 (executable)
index 0000000..2fe47c4
--- /dev/null
@@ -0,0 +1,30 @@
+#!/usr/bin/perl
+
+use strict;
+use Getopt::Std;
+use FS::UID qw(adminsuidsetup);
+
+&untaint_argv; #what it sounds like  (eww)
+use vars qw(%opt);
+getopts("a:", \%opt);
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+use FS::Cron::agent_email qw(agent_email);
+agent_email(%opt);
+
+###
+# subroutines
+###
+
+sub untaint_argv {
+  foreach $_ ( $[ .. $#ARGV ) { #untaint @ARGV
+    #$ARGV[$_] =~ /^([\w\-\/]*)$/ || die "Illegal arguement \"$ARGV[$_]\"";
+    # Date::Parse
+    $ARGV[$_] =~ /^(.*)$/ || die "Illegal arguement \"$ARGV[$_]\"";
+    $ARGV[$_]=$1;
+  }
+}
+
+1;
old mode 100644 (file)
new mode 100755 (executable)
diff --git a/bin/cust_bill.export b/bin/cust_bill.export
new file mode 100755 (executable)
index 0000000..40c32e5
--- /dev/null
@@ -0,0 +1,49 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::cust_bill;
+use Date::Format;
+
+my @fields = qw(
+  invnum
+  custnum
+);
+
+push @fields,
+  { 'header'   => 'Date',
+    'callback' => sub { time2str('%x', shift->_date); },
+  },
+;
+
+push @fields, qw( charged owed );
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+my $agentnum = shift or die &usage;
+
+my $csv = new Text::CSV_XS;
+
+$csv->combine( map { ref($_) ? $_->{'header'} : $_ } @fields ) or die;
+print $csv->string."\n";
+
+my @cust_bill = qsearch({
+  'table'     => 'cust_bill',
+  'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => {},
+  'extra_sql' => "WHERE cust_main.agentnum = $agentnum",
+});
+
+foreach my $cust_bill ( @cust_bill ) {
+  $csv->combine( map { ref($_) ? &{$_->{'callback'}}($cust_bill)
+                               : $cust_bill->$_()
+                     } 
+                   @fields
+               ) or die;
+  print $csv->string."\n";
+}
+
+1;
old mode 100644 (file)
new mode 100755 (executable)
index 17e48fb..f8a1580
@@ -13,7 +13,9 @@ my $custnum = shift or die &usage;
 my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
   or die "unknown custnum $custnum\n";
 
-$cust_main->bill_and_collect( debug=>2, check_freq=>'1d' );
+$FS::cust_main::DEBUG = 3;
+
+$cust_main->bill_and_collect( debug=>3, check_freq=>'1d' );
 
 sub usage {
   die "Usage:\n  cust_main-bill_now user custnum\n";
diff --git a/bin/cust_main.export b/bin/cust_main.export
new file mode 100755 (executable)
index 0000000..4adfeeb
--- /dev/null
@@ -0,0 +1,109 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::cust_main;
+
+my @fields = qw(
+  custnum
+  status
+  last
+  first
+  company
+  address1
+  address2
+  city
+  county
+  state
+  zip
+  country
+  daytime
+  night
+  mobile
+  fax
+  ship_address1
+  ship_address2
+  ship_city
+  ship_county
+  ship_state
+  ship_zip
+  ship_country
+  ship_daytime
+  ship_night
+  ship_mobile
+  ship_fax
+  invoicing_list_emailonly_scalar
+  payby
+  balance
+);
+
+push @fields,
+  #Billing Type: Credit Card
+  { 'header'   => 'Credit Card number',
+    'callback' => sub { my $c_m = shift;
+                        $c_m->payby =~ /^(CARD|DCRD)$/ ? $c_m->payinfo : '' ;
+                      },
+  },
+  { 'header'   => 'Expiration on card',
+    'callback' => sub { my $c_m = shift;
+                        return '' unless $c_m->payby =~ /^(CARD|DCRD)$/;
+                        $c_m->paydate =~ /^(\d{4})-(\d{2})-\d{2}$/ or die;
+                        return "$2/$1";
+                      },
+  },
+  { 'header'   => 'Name on card',
+    'callback' => sub { my $c_m = shift;
+                        $c_m->payby =~ /^(CARD|DCRD)$/ ? $c_m->paydname : '' ;
+                      },
+  },
+
+  #Billing Type: Electronic check
+  { 'header'   => 'ABA/Routing number',
+    'callback' => sub { my $c_m = shift;
+                        return '' unless $c_m->payby =~ /^(CHEK|DCHK)$/;
+                        (split('@', $c_m->payinfo))[1];
+                      },
+  },
+  { 'header'   => 'Account number',
+    'callback' => sub { my $c_m = shift;
+                        return '' unless $c_m->payby =~ /^(CHEK|DCHK)$/;
+                        (split('@', $c_m->payinfo))[0];
+                      },
+  },
+  { 'header'   => 'Account type',
+    'callback' => sub { my $c_m = shift;
+                        $c_m->payby =~ /^(CHEK|DCHK)$/ ? $c_m->paytype : '';
+                      },
+  },
+  { 'header'   => 'Bank Name',
+    'callback' => sub { my $c_m = shift;
+                        $c_m->payby =~ /^(CHEK|DCHK)$/ ? $c_m->payname : '';
+                      },
+  },
+
+;
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+my $agentnum = shift or die &usage;
+
+my $csv = new Text::CSV_XS;
+
+$csv->combine( map { ref($_) ? $_->{'header'} : $_ } @fields ) or die;
+print $csv->string."\n";
+
+my @cust_main = qsearch('cust_main', { 'agentnum'=>$agentnum });
+
+foreach my $cust_main( @cust_main ) {
+  $csv->combine( map { ref($_) ? &{$_->{'callback'}}($cust_main)
+                               : $cust_main->$_()
+                     } 
+                   @fields
+               ) or die;
+  print $csv->string."\n";
+}
+
+1;
diff --git a/bin/cust_pkg.export b/bin/cust_pkg.export
new file mode 100755 (executable)
index 0000000..f922e02
--- /dev/null
@@ -0,0 +1,61 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::cust_pkg;
+use Date::Format;
+
+my @fields = qw(
+  pkgnum
+  custnum
+  status
+  pkgpart
+);
+
+push @fields,
+  { 'header'   => 'Package',
+    'callback' => sub { shift->part_pkg->pkg_comment('nopkgpart'=>1) },
+  },
+  map { 
+    my $field = $_;
+    { 'header'   => $field,
+      'callback' => sub { my $d = shift->get($field) or return '';
+                          time2str('%x', $d); # %X", $d);
+                        },
+    };
+  } qw( order_date start_date setup last_bill bill
+        adjourn susp resume
+        expire cancel uncancel
+        contract_end
+  )
+;
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+my $agentnum = shift or die &usage;
+
+my $csv = new Text::CSV_XS;
+
+$csv->combine( map { ref($_) ? $_->{'header'} : $_ } @fields ) or die;
+print $csv->string."\n";
+
+my @cust_pkg = qsearch({
+  'table'     => 'cust_pkg',
+  'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => {},
+  'extra_sql' => "WHERE cust_main.agentnum = $agentnum",
+});
+
+foreach my $cust_pkg ( @cust_pkg ) {
+  $csv->combine( map { ref($_) ? &{$_->{'callback'}}($cust_pkg)
+                               : $cust_pkg->$_()
+                     } 
+                   @fields
+               ) or die;
+  print $csv->string."\n";
+}
+
+1;
index ecb7f91..1ec998f 100755 (executable)
--- a/bin/pod2x
+++ b/bin/pod2x
@@ -7,12 +7,15 @@ chomp( my $mw_password = `cat .mw-password` );
 
 my $site_perl = "./FS";
 #my $html = "Freeside:1.7:Documentation:Developer";
-my $html = "Freeside:1.9:Documentation:Developer";
+#my $html = "Freeside:1.9:Documentation:Developer";
+my $html = "Freeside:3:Documentation:Developer";
 
 foreach my $dir (
   $html,
-  map "$html/$_", qw( bin FS FS/UI FS/part_export FS/part_pkg
+  map "$html/$_", qw( bin FS
+                      FS/cdr FS/cust_main FS/cust_pkg FS/detail_format
                       FS/part_event FS/part_event/Condition FS/part_event/Action
+                      FS/part_export FS/part_pkg FS/pay_batch
                       FS/ClientAPI FS/Cron FS/Misc FS/Report FS/Report/Table
                       FS/TicketSystem FS/UI
                       FS/SelfService
@@ -43,6 +46,7 @@ foreach my $file (
 use WWW::Mediawiki::Client;
 my $mvs = WWW::Mediawiki::Client->new(
             'host'           => 'www.freeside.biz',
+            'protocol'       => 'https',
             'wiki_path'      => 'mediawiki/index.php',
             'username'       => $mw_username,
             'password'       => $mw_password,
diff --git a/bin/svc_acct.export b/bin/svc_acct.export
new file mode 100755 (executable)
index 0000000..dba4ac9
--- /dev/null
@@ -0,0 +1,54 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::svc_acct;
+
+my @fields = (
+  { 'header'   => 'pkgnum',
+    'callback' => sub { shift->cust_svc->pkgnum; },
+  },
+  { 'header'   => 'svcpart',
+    'callback' => sub { shift->cust_svc->svcpart; },
+  },
+  { 'header'   => 'Service',
+    'callback' => sub { shift->cust_svc->part_svc->svc; },
+  },
+  qw(
+    username
+    _password
+    slipip
+  )
+);
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+my $agentnum = shift or die &usage;
+
+my $csv = new Text::CSV_XS;
+
+$csv->combine( map { ref($_) ? $_->{'header'} : $_ } @fields ) or die;
+print $csv->string."\n";
+
+my @svc_acct = qsearch({
+  'table'     => 'svc_acct',
+  'addl_from' => 'LEFT JOIN cust_svc USING (svcnum)
+                  LEFT JOIN cust_pkg USING (pkgnum)
+                  LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => {},
+  'extra_sql' => "WHERE cust_main.agentnum = $agentnum",
+});
+
+foreach my $svc_acct ( @svc_acct ) {
+  $csv->combine( map { ref($_) ? &{$_->{'callback'}}($svc_acct)
+                               : $svc_acct->$_()
+                     } 
+                   @fields
+               ) or die;
+  print $csv->string."\n";
+}
+
+1;
diff --git a/bin/svc_broadband.export b/bin/svc_broadband.export
new file mode 100755 (executable)
index 0000000..1d5c713
--- /dev/null
@@ -0,0 +1,59 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::svc_broadband;
+
+my @fields = (
+  { 'header'   => 'pkgnum',
+    'callback' => sub { shift->cust_svc->pkgnum; },
+  },
+  { 'header'   => 'svcpart',
+    'callback' => sub { shift->cust_svc->svcpart; },
+  },
+  { 'header'   => 'Service',
+    'callback' => sub { shift->cust_svc->part_svc->svc; },
+  },
+  qw(
+    description
+    speed_up
+    speed_down
+    ip_addr
+    mac_addr
+    latitude
+    longitude
+  )
+);
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+my $agentnum = shift or die &usage;
+
+my $csv = new Text::CSV_XS;
+
+$csv->combine( map { ref($_) ? $_->{'header'} : $_ } @fields ) or die;
+print $csv->string."\n";
+
+my @svc_broadband = qsearch({
+  'select'    => 'svc_broadband.*',
+  'table'     => 'svc_broadband',
+  'addl_from' => 'LEFT JOIN cust_svc USING (svcnum)
+                  LEFT JOIN cust_pkg USING (pkgnum)
+                  LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => {},
+  'extra_sql' => "WHERE cust_main.agentnum = $agentnum",
+});
+
+foreach my $svc_broadband ( @svc_broadband ) {
+  $csv->combine( map { ref($_) ? &{$_->{'callback'}}($svc_broadband)
+                               : $svc_broadband->$_()
+                     } 
+                   @fields
+               ) or die;
+  print $csv->string."\n";
+}
+
+1;
diff --git a/bin/svc_phone.export b/bin/svc_phone.export
new file mode 100755 (executable)
index 0000000..aa0eb20
--- /dev/null
@@ -0,0 +1,55 @@
+#!/usr/bin/perl
+
+use strict;
+use Text::CSV_XS;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::svc_phone;
+
+my @fields = (
+  { 'header'   => 'pkgnum',
+    'callback' => sub { shift->cust_svc->pkgnum; },
+  },
+  { 'header'   => 'svcpart',
+    'callback' => sub { shift->cust_svc->svcpart; },
+  },
+  { 'header'   => 'Service',
+    'callback' => sub { shift->cust_svc->part_svc->svc; },
+  },
+  qw(
+    phonenum
+    pin
+    sip_password
+    phone_name
+  )
+);
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+my $agentnum = shift or die &usage;
+
+my $csv = new Text::CSV_XS;
+
+$csv->combine( map { ref($_) ? $_->{'header'} : $_ } @fields ) or die;
+print $csv->string."\n";
+
+my @svc_phone = qsearch({
+  'table'     => 'svc_phone',
+  'addl_from' => 'LEFT JOIN cust_svc USING (svcnum)
+                  LEFT JOIN cust_pkg USING (pkgnum)
+                  LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => {},
+  'extra_sql' => "WHERE cust_main.agentnum = $agentnum",
+});
+
+foreach my $svc_phone ( @svc_phone ) {
+  $csv->combine( map { ref($_) ? &{$_->{'callback'}}($svc_phone)
+                               : $svc_phone->$_()
+                     } 
+                   @fields
+               ) or die;
+  print $csv->string."\n";
+}
+
+1;
diff --git a/bin/tax_location.upgrade b/bin/tax_location.upgrade
new file mode 100755 (executable)
index 0000000..8140945
--- /dev/null
@@ -0,0 +1,31 @@
+#!/usr/bin/perl
+
+use FS::UID qw(adminsuidsetup);
+use FS::Record;
+use FS::cust_bill_pkg;
+use Date::Parse qw(str2time);
+use Getopt::Std;
+getopts('s:e:');
+my $username = shift @ARGV;
+
+if (!$username) {
+  print
+"Usage: tax_location.upgrade [ -s START ] [ -e END ] username
+
+This script creates cust_bill_pkg_tax_location and cust_tax_exempt_pkg records
+for existing sales tax records prior to the 3.0 cust_location changes.  Changes
+will be committed immediately; back up your data and run 'make
+install-perl-modules' and 'freeside-upgrade' before running this script.  
+START and END specify an optional range of invoice dates to upgrade.
+
+";
+  exit(1);
+}
+
+my %opt;
+$opt{s} = str2time($opt_s) if $opt_s;
+$opt{e} = str2time($opt_e) if $opt_e;
+
+adminsuidsetup($username);
+FS::cust_bill_pkg->upgrade_tax_location(%opt);
+1;
diff --git a/bin/v-rate-reimport b/bin/v-rate-reimport
new file mode 100755 (executable)
index 0000000..8b53058
--- /dev/null
@@ -0,0 +1,172 @@
+#!/usr/bin/perl
+
+use strict;
+use DBI;
+use FS::UID qw(adminsuidsetup);
+use FS::rate_prefix;
+use FS::rate_region;
+use FS::rate_detail;
+use FS::Record qw(qsearch qsearchs dbh);
+
+# #delete from rate;
+# Create interstate and intrastate rate plans
+#
+# #delete from rate_detail;
+# #delete from rate_region;
+# #delete from rate_prefix;
+
+# Assumption: 1-to-1 relationship between rate_region and rate_prefix, with
+# two rate_detail per rate_region: one for interstate; one for intrastate
+#
+# run the script, setting the appropriate values below.
+
+####### SET THESE! ####################
+
+my $DRY_RUN = 0;
+
+my $intra_ratenum = 5;
+my $inter_ratenum = 6;
+my $intra_class = 1;
+my $inter_class = 2;
+#my $file = "/home/levinse/domestic_interstate.xls";
+#my $file = "/home/ivan/vnes/New VNES Rate Table.xlsx";
+my $file = "/home/ivan/New VNES Rate Table.csv";
+#my $sheet_name = 'Sheet1';
+#######################################
+
+my $user = shift or die "no user specified";
+adminsuidsetup $user;
+
+local $SIG{HUP} = 'IGNORE';
+local $SIG{INT} = 'IGNORE';
+local $SIG{QUIT} = 'IGNORE';
+local $SIG{TERM} = 'IGNORE';
+local $SIG{TSTP} = 'IGNORE';
+local $SIG{PIPE} = 'IGNORE';
+
+my $oldAutoCommit = $FS::UID::AutoCommit;
+local $FS::UID::AutoCommit = 0;
+my $dbhfs = dbh;
+
+#my $dbh = DBI->connect("DBI:Excel:file=$file")
+#  or die "can't connect: $DBI::errstr";
+
+#my $sth = $dbh->prepare("select * from $sheet_name")
+#  or die "can't prepare: ". $dbh->errstr;
+#$sth->execute
+#  or die "can't execute: ". $sth->errstr;
+
+use Text::CSV_XS;
+my $csv = Text::CSV_XS->new or die Text::CSV->error_diag;
+
+open(my $fh, "<$file") or die $!;
+my $header = scalar(<$fh>); #NPA, NXX, LATA, State, Intrastate, Interstate
+
+my @rp_cache = qsearch('rate_prefix', {} );# or die "can't cache rate_prefix";
+my %rp_cache = map { $_->npa => $_ } @rp_cache;
+
+sub fatal {
+    my $msg = shift;
+    $dbhfs->rollback; # if $oldAutoCommit;
+    die $msg;
+}
+
+while ( my $row = $csv->getline($fh) ) {
+
+  #my $lata = $row->{'lata'};
+  #my $ocn = $row->{'ocn'};
+  #my $state = $row->{'state'};
+  #my $rate = $row->{'rate'};
+  #my $npanxx = $row->{'lrn'};
+
+  #NPA, NXX, LATA, State, Intrastate, Interstate
+  my $npa        = $row->[0];
+  my $nxx        = $row->[1];
+  my $lata       = $row->[2];
+  my $state      = $row->[3];
+  ( my $intra_rate = $row->[4] ) =~ s/^\s*\$//;
+  ( my $inter_rate = $row->[5] ) =~ s/^\s*\$//;
+
+  #in the new data, instead of being "$-", these are all identical to the
+  #rate from the immediatelly preceeding cell/NPANXX... probably an artifact
+  #rather than real rates then?  so also skipping this import
+  #import
+  next if $lata == '99999';
+
+  my $error = '';
+
+  my $rp;
+  if ( $rp_cache{$npa.$nxx} ) {
+      $rp = $rp_cache{$npa.$nxx};
+  } 
+  else {
+
+     #warn "inserting new rate_region / rate_prefix for $npa-$nxx\n";
+     die "new rate_region / rate_prefix $npa-$nxx\n";
+
+     my $rr = new FS::rate_region { 'regionname' => $state };
+     $error = $rr->insert;
+     fatal("can't insert rr") if $error;
+
+     $rp = new FS::rate_prefix {   'countrycode'   => '1',
+                                   'npa'           => $npa.$nxx, #$npanxx
+                                   #'ocn'           => $ocn,
+                                   'state'         => $state,
+                                   'latanum'       => $lata,
+                                   'regionnum'     => $rr->regionnum,
+                               }; 
+     $error = $rp->insert;
+     fatal("can't insert rp") if $error;
+     $rp_cache{$npa.$nxx} = $rp;
+  }
+
+  #use Data::Dumper;
+  #warn Dumper($rp);
+
+  my %hash = ( 'dest_regionnum'  => $rp->regionnum, );
+
+  my %intra_hash = ( 'ratenum'     => $intra_ratenum,
+                     'intra_class' => $intra_class,
+                     %hash,
+                   );
+
+  my $intra_rd = qsearchs( 'rate_detail', \%intra_hash )
+                 || die; #new FS::rate_detail   \%intra_hash;
+  $intra_rd->min_included( 0 );
+  $intra_rd->sec_granularity( 6 ); #60
+  die if $intra_rd->min_charge > 0;
+  $intra_rd->min_charge( $intra_rate );
+
+  #$error = $intra_rd->ratedetailnum ? $intra_rd->replace : $intra_rd->insert;
+  $error = $intra_rd->replace;
+  fatal("can't insert/replace (intra) rd: $error") if $error;
+
+  my %inter_hash = ( 'ratenum'     => $inter_ratenum,
+                     'inter_class' => $inter_class,
+                     %hash,
+                   );
+
+  my $inter_rd = qsearchs( 'rate_detail', \%inter_hash )
+                 || die; #new FS::rate_detail \%inter_hash;
+
+  $inter_rd->min_included( 0 );
+  $inter_rd->sec_granularity( 6 ); #60
+  die if $inter_rd->min_charge > 0;
+  $inter_rd->min_charge( $inter_rate );
+
+  #$error = $inter_rd->ratedetailnum ? $inter_rd->replace : $inter_rd->insert;
+  $error = $inter_rd->replace;
+  fatal("can't insert/replace (inter) rd: $error") if $error;
+}
+$csv->eof or $csv->error_diag ();
+close $fh;
+
+if ( $DRY_RUN ) {
+  $dbhfs->rollback or die $dbhfs->errstr; # if $oldAutoCommit;
+} else {
+  $dbhfs->commit or die $dbhfs->errstr; # if $oldAutoCommit;
+}
+
+1;
+
index 772c2eb..d56a7fb 100644 (file)
@@ -19,7 +19,7 @@
 \r
 \documentclass[letterpaper]{article}\r
 \r
-\usepackage{fancyhdr,lastpage,ifthen,array,fslongtable,afterpage,caption,multirow,bigstrut}\r
+\usepackage{fancyhdr,lastpage,ifthen,array,longtable,afterpage,caption,multirow,bigstrut}\r
 \usepackage{graphicx}                  % required for logo graphic\r
 \usepackage[utf8]{inputenc}             % multilanguage support\r
 \usepackage[T1]{fontenc}\r
index 772c2eb..d56a7fb 100644 (file)
@@ -19,7 +19,7 @@
 \r
 \documentclass[letterpaper]{article}\r
 \r
-\usepackage{fancyhdr,lastpage,ifthen,array,fslongtable,afterpage,caption,multirow,bigstrut}\r
+\usepackage{fancyhdr,lastpage,ifthen,array,longtable,afterpage,caption,multirow,bigstrut}\r
 \usepackage{graphicx}                  % required for logo graphic\r
 \usepackage[utf8]{inputenc}             % multilanguage support\r
 \usepackage[T1]{fontenc}\r
diff --git a/etc/fslongtable.sty b/etc/fslongtable.sty
deleted file mode 100644 (file)
index e322b55..0000000
+++ /dev/null
@@ -1,438 +0,0 @@
-%%
-%% This is file `fslongtable.sty',
-%%
-%% Copyright 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003
-%% The LaTeX3 Project and any individual authors listed elsewhere
-%% in this file.
-%% 
-%% This file was forked from file(s) of the Standard LaTeX `Tools Bundle'.
-%% This file includes a new length LTextracouponspace which modifies
-%% the behavior of the package at the end of a page.  This feature 
-%% and package is not supported or acknowledged by Dave Carlisle.
-%% Do not contact him for such support.
-%% --------------------------------------------------------------------------
-%% 
-%% It may be distributed and/or modified under the
-%% conditions of the LaTeX Project Public License, either version 1.3
-%% of this license or (at your option) any later version.
-%% The latest version of this license is in
-%%    http://www.latex-project.org/lppl.txt
-%% and version 1.3 or later is part of all distributions of LaTeX
-%% version 2003/12/01 or later.
-%% 
-%% File: longtable.dtx Copyright (C) 1990-2001 David Carlisle
-%% File: fslongtable.sty Copyright (C) 2008 Jeff Finucane
-\NeedsTeXFormat{LaTeX2e}[1995/06/01]
-\ProvidesPackage{longtable}
-          [2004/02/01 v4.11 Multi-page Table package (DPC)]
-\def\LT@err{\PackageError{longtable}}
-\def\LT@warn{\PackageWarning{longtable}}
-\def\LT@final@warn{%
-  \AtEndDocument{%
-    \LT@warn{Table \@width s have changed. Rerun LaTeX.\@gobbletwo}}%
-  \global\let\LT@final@warn\relax}
-\DeclareOption{errorshow}{%
-  \def\LT@warn{\PackageInfo{longtable}}}
-\DeclareOption{pausing}{%
-  \def\LT@warn#1{%
-    \LT@err{#1}{This is not really an error}}}
-\DeclareOption{set}{}
-\DeclareOption{final}{}
-\ProcessOptions
-\newskip\LTleft       \LTleft=\fill
-\newskip\LTright      \LTright=\fill
-\newskip\LTpre        \LTpre=\bigskipamount
-\newskip\LTpost       \LTpost=\bigskipamount
-\newcount\LTchunksize \LTchunksize=20
-\let\c@LTchunksize\LTchunksize
-\newdimen\LTcapwidth  \LTcapwidth=4in
-\newlength\LTextracouponspace
-\newbox\LT@head
-\newbox\LT@firsthead
-\newbox\LT@foot
-\newbox\LT@lastfoot
-\newcount\LT@cols
-\newcount\LT@rows
-\newcounter{LT@tables}
-\newcounter{LT@chunks}[LT@tables]
-\ifx\c@table\undefined
-  \newcounter{table}
-  \def\fnum@table{\tablename~\thetable}
-\fi
-\ifx\tablename\undefined
-  \def\tablename{Table}
-\fi
-\newtoks\LT@p@ftn
-\mathchardef\LT@end@pen=30000
-\def\longtable{%
-  \par
-  \ifx\multicols\@undefined
-  \else
-     \ifnum\col@number>\@ne
-       \@twocolumntrue
-     \fi
-  \fi
-  \if@twocolumn
-    \LT@err{longtable not in 1-column mode}\@ehc
-  \fi
-  \begingroup
-  \@ifnextchar[\LT@array{\LT@array[x]}}
-\def\LT@array[#1]#2{%
-  \refstepcounter{table}\stepcounter{LT@tables}%
-  \if l#1%
-    \LTleft\z@ \LTright\fill
-  \else\if r#1%
-    \LTleft\fill \LTright\z@
-  \else\if c#1%
-    \LTleft\fill \LTright\fill
-  \fi\fi\fi
-  \let\LT@mcol\multicolumn
-  \let\LT@@tabarray\@tabarray
-  \let\LT@@hl\hline
-  \def\@tabarray{%
-    \let\hline\LT@@hl
-    \LT@@tabarray}%
-  \let\\\LT@tabularcr\let\tabularnewline\\%
-  \def\newpage{\noalign{\break}}%
-  \def\pagebreak{\noalign{\ifnum`}=0\fi\@testopt{\LT@no@pgbk-}4}%
-  \def\nopagebreak{\noalign{\ifnum`}=0\fi\@testopt\LT@no@pgbk4}%
-  \let\hline\LT@hline \let\kill\LT@kill\let\caption\LT@caption
-  \@tempdima\ht\strutbox
-  \let\@endpbox\LT@endpbox
-  \ifx\extrarowheight\@undefined
-    \let\@acol\@tabacol
-    \let\@classz\@tabclassz \let\@classiv\@tabclassiv
-    \def\@startpbox{\vtop\LT@startpbox}%
-    \let\@@startpbox\@startpbox
-    \let\@@endpbox\@endpbox
-    \let\LT@LL@FM@cr\@tabularcr
-  \else
-    \advance\@tempdima\extrarowheight
-    \col@sep\tabcolsep
-    \let\@startpbox\LT@startpbox\let\LT@LL@FM@cr\@arraycr
-  \fi
-  \setbox\@arstrutbox\hbox{\vrule
-    \@height \arraystretch \@tempdima
-    \@depth \arraystretch \dp \strutbox
-    \@width \z@}%
-  \let\@sharp##\let\protect\relax
-   \begingroup
-    \@mkpream{#2}%
-    \xdef\LT@bchunk{%
-       \global\advance\c@LT@chunks\@ne
-       \global\LT@rows\z@\setbox\z@\vbox\bgroup
-       \LT@setprevdepth
-       \tabskip\LTleft \noexpand\halign to\hsize\bgroup
-      \tabskip\z@ \@arstrut \@preamble \tabskip\LTright \cr}%
-  \endgroup
-  \expandafter\LT@nofcols\LT@bchunk&\LT@nofcols
-  \LT@make@row
-  \m@th\let\par\@empty
-  \everycr{}\lineskip\z@\baselineskip\z@
-  \LT@bchunk}
-\def\LT@no@pgbk#1[#2]{\penalty #1\@getpen{#2}\ifnum`{=0\fi}}
-\def\LT@start{%
-  \let\LT@start\endgraf
-  \endgraf\penalty\z@\vskip\LTpre
-  \dimen@\pagetotal
-  \advance\dimen@ \ht\ifvoid\LT@firsthead\LT@head\else\LT@firsthead\fi
-  \advance\dimen@ \dp\ifvoid\LT@firsthead\LT@head\else\LT@firsthead\fi
-  \advance\dimen@ \ht\LT@foot
-  \dimen@ii\vfuzz
-  \vfuzz\maxdimen
-    \setbox\tw@\copy\z@
-    \setbox\tw@\vsplit\tw@ to \ht\@arstrutbox
-    \setbox\tw@\vbox{\unvbox\tw@}%
-  \vfuzz\dimen@ii
-  \advance\dimen@ \ht
-        \ifdim\ht\@arstrutbox>\ht\tw@\@arstrutbox\else\tw@\fi
-  \advance\dimen@\dp
-        \ifdim\dp\@arstrutbox>\dp\tw@\@arstrutbox\else\tw@\fi
-  \advance\dimen@ -\pagegoal
-  \ifdim \dimen@>\z@\vfil\break\fi
-      \global\@colroom\@colht
-  \ifnum\thepage=1
-    \advance\vsize-\LTextracouponspace
-    \dimen@\pagegoal\advance\dimen@-\LTextracouponspace\pagegoal\dimen@
-  \fi
-  \ifvoid\LT@foot\else
-    \advance\vsize-\ht\LT@foot
-    \global\advance\@colroom-\ht\LT@foot
-    \dimen@\pagegoal\advance\dimen@-\ht\LT@foot\pagegoal\dimen@
-    \maxdepth\z@
-  \fi
-  \ifvoid\LT@firsthead\copy\LT@head\else\box\LT@firsthead\fi\nobreak
-  \output{\LT@output}}
-\def\endlongtable{%
-  \crcr
-  \noalign{%
-    \let\LT@entry\LT@entry@chop
-    \xdef\LT@save@row{\LT@save@row}}%
-  \LT@echunk
-  \LT@start
-  \unvbox\z@
-  \LT@get@widths
-  \if@filesw
-    {\let\LT@entry\LT@entry@write\immediate\write\@auxout{%
-      \gdef\expandafter\noexpand
-        \csname LT@\romannumeral\c@LT@tables\endcsname
-          {\LT@save@row}}}%
-  \fi
-  \ifx\LT@save@row\LT@@save@row
-  \else
-    \LT@warn{Column \@width s have changed\MessageBreak
-             in table \thetable}%
-    \LT@final@warn
-  \fi
-  \endgraf\penalty -\LT@end@pen
-  \endgroup
-  \global\@mparbottom\z@
-  \pagegoal\vsize
-  \endgraf\penalty\z@\addvspace\LTpost
-  \ifvoid\footins\else\insert\footins{}\fi}
-\def\LT@nofcols#1&{%
-  \futurelet\@let@token\LT@n@fcols}
-\def\LT@n@fcols{%
-  \advance\LT@cols\@ne
-  \ifx\@let@token\LT@nofcols
-    \expandafter\@gobble
-  \else
-    \expandafter\LT@nofcols
-  \fi}
-\def\LT@tabularcr{%
-  \relax\iffalse{\fi\ifnum0=`}\fi
-  \@ifstar
-    {\def\crcr{\LT@crcr\noalign{\nobreak}}\let\cr\crcr
-     \LT@t@bularcr}%
-    {\LT@t@bularcr}}
-\let\LT@crcr\crcr
-\let\LT@setprevdepth\relax
-\def\LT@t@bularcr{%
-  \global\advance\LT@rows\@ne
-  \ifnum\LT@rows=\LTchunksize
-    \gdef\LT@setprevdepth{%
-      \prevdepth\z@\global
-      \global\let\LT@setprevdepth\relax}%
-    \expandafter\LT@xtabularcr
-  \else
-    \ifnum0=`{}\fi
-    \expandafter\LT@LL@FM@cr
-  \fi}
-\def\LT@xtabularcr{%
-  \@ifnextchar[\LT@argtabularcr\LT@ntabularcr}
-\def\LT@ntabularcr{%
-  \ifnum0=`{}\fi
-  \LT@echunk
-  \LT@start
-  \unvbox\z@
-  \LT@get@widths
-  \LT@bchunk}
-\def\LT@argtabularcr[#1]{%
-  \ifnum0=`{}\fi
-  \ifdim #1>\z@
-    \unskip\@xargarraycr{#1}%
-  \else
-    \@yargarraycr{#1}%
-  \fi
-  \LT@echunk
-  \LT@start
-  \unvbox\z@
-  \LT@get@widths
-  \LT@bchunk}
-\def\LT@echunk{%
-  \crcr\LT@save@row\cr\egroup
-  \global\setbox\@ne\lastbox
-    \unskip
-  \egroup}
-\def\LT@entry#1#2{%
-  \ifhmode\@firstofone{&}\fi\omit
-  \ifnum#1=\c@LT@chunks
-  \else
-    \kern#2\relax
-  \fi}
-\def\LT@entry@chop#1#2{%
-  \noexpand\LT@entry
-    {\ifnum#1>\c@LT@chunks
-       1}{0pt%
-     \else
-       #1}{#2%
-     \fi}}
-\def\LT@entry@write{%
-  \noexpand\LT@entry^^J%
-  \@spaces}
-\def\LT@kill{%
-  \LT@echunk
-  \LT@get@widths
-  \expandafter\LT@rebox\LT@bchunk}
-\def\LT@rebox#1\bgroup{%
-  #1\bgroup
-  \unvbox\z@
-  \unskip
-  \setbox\z@\lastbox}
-\def\LT@blank@row{%
-  \xdef\LT@save@row{\expandafter\LT@build@blank
-    \romannumeral\number\LT@cols 001 }}
-\def\LT@build@blank#1{%
-  \if#1m%
-    \noexpand\LT@entry{1}{0pt}%
-    \expandafter\LT@build@blank
-  \fi}
-\def\LT@make@row{%
-  \global\expandafter\let\expandafter\LT@save@row
-    \csname LT@\romannumeral\c@LT@tables\endcsname
-  \ifx\LT@save@row\relax
-    \LT@blank@row
-  \else
-    {\let\LT@entry\or
-     \if!%
-         \ifcase\expandafter\expandafter\expandafter\LT@cols
-         \expandafter\@gobble\LT@save@row
-         \or
-         \else
-           \relax
-         \fi
-        !%
-     \else
-       \aftergroup\LT@blank@row
-     \fi}%
-  \fi}
-\let\setlongtables\relax
-\def\LT@get@widths{%
-  \setbox\tw@\hbox{%
-    \unhbox\@ne
-    \let\LT@old@row\LT@save@row
-    \global\let\LT@save@row\@empty
-    \count@\LT@cols
-    \loop
-      \unskip
-      \setbox\tw@\lastbox
-    \ifhbox\tw@
-      \LT@def@row
-      \advance\count@\m@ne
-    \repeat}%
-  \ifx\LT@@save@row\@undefined
-    \let\LT@@save@row\LT@save@row
-  \fi}
-\def\LT@def@row{%
-  \let\LT@entry\or
-  \edef\@tempa{%
-    \ifcase\expandafter\count@\LT@old@row
-    \else
-      {1}{0pt}%
-    \fi}%
-  \let\LT@entry\relax
-  \xdef\LT@save@row{%
-    \LT@entry
-    \expandafter\LT@max@sel\@tempa
-    \LT@save@row}}
-\def\LT@max@sel#1#2{%
-  {\ifdim#2=\wd\tw@
-     #1%
-   \else
-     \number\c@LT@chunks
-   \fi}%
-  {\the\wd\tw@}}
-\def\LT@hline{%
-  \noalign{\ifnum0=`}\fi
-    \penalty\@M
-    \futurelet\@let@token\LT@@hline}
-\def\LT@@hline{%
-  \ifx\@let@token\hline
-    \global\let\@gtempa\@gobble
-    \gdef\LT@sep{\penalty-\@medpenalty\vskip\doublerulesep}%
-  \else
-    \global\let\@gtempa\@empty
-    \gdef\LT@sep{\penalty-\@lowpenalty\vskip-\arrayrulewidth}%
-  \fi
-  \ifnum0=`{\fi}%
-  \multispan\LT@cols
-     \unskip\leaders\hrule\@height\arrayrulewidth\hfill\cr
-  \noalign{\LT@sep}%
-  \multispan\LT@cols
-     \unskip\leaders\hrule\@height\arrayrulewidth\hfill\cr
-  \noalign{\penalty\@M}%
-  \@gtempa}
-\def\LT@caption{%
-  \noalign\bgroup
-    \@ifnextchar[{\egroup\LT@c@ption\@firstofone}\LT@capti@n}
-\def\LT@c@ption#1[#2]#3{%
-  \LT@makecaption#1\fnum@table{#3}%
-  \def\@tempa{#2}%
-  \ifx\@tempa\@empty\else
-     {\let\\\space
-     \addcontentsline{lot}{table}{\protect\numberline{\thetable}{#2}}}%
-  \fi}
-\def\LT@capti@n{%
-  \@ifstar
-    {\egroup\LT@c@ption\@gobble[]}%
-    {\egroup\@xdblarg{\LT@c@ption\@firstofone}}}
-\def\LT@makecaption#1#2#3{%
-  \LT@mcol\LT@cols c{\hbox to\z@{\hss\parbox[t]\LTcapwidth{%
-    \sbox\@tempboxa{#1{#2: }#3}%
-    \ifdim\wd\@tempboxa>\hsize
-      #1{#2: }#3%
-    \else
-      \hbox to\hsize{\hfil\box\@tempboxa\hfil}%
-    \fi
-    \endgraf\vskip\baselineskip}%
-  \hss}}}
-\def\LT@output{%
-  \ifnum\outputpenalty <-\@Mi
-    \ifnum\outputpenalty > -\LT@end@pen
-      \LT@err{floats and marginpars not allowed in a longtable}\@ehc
-    \else
-      \setbox\z@\vbox{\unvbox\@cclv}%
-      \ifdim \ht\LT@lastfoot>\ht\LT@foot
-        \dimen@\pagegoal
-        \advance\dimen@-\ht\LT@lastfoot
-        \ifdim\dimen@<\ht\z@
-          \setbox\@cclv\vbox{\unvbox\z@\copy\LT@foot\vss}%
-          \@makecol
-          \@outputpage
-          \setbox\z@\vbox{\box\LT@head}%
-        \fi
-      \fi
-      \global\@colroom\@colht
-      \global\vsize\@colht
-      \vbox
-        {\unvbox\z@\box\ifvoid\LT@lastfoot\LT@foot\else\LT@lastfoot\fi}%
-    \fi
-  \else
-    \setbox\@cclv\vbox{\unvbox\@cclv\copy\LT@foot\vss}%
-    \@makecol
-    \@outputpage
-      \global\vsize\@colroom
-    \copy\LT@head\nobreak
-  \fi}
-\def\LT@end@hd@ft#1{%
-  \LT@echunk
-  \ifx\LT@start\endgraf
-    \LT@err
-     {Longtable head or foot not at start of table}%
-     {Increase LTchunksize}%
-  \fi
-  \setbox#1\box\z@
-  \LT@get@widths
-  \LT@bchunk}
-\def\endfirsthead{\LT@end@hd@ft\LT@firsthead}
-\def\endhead{\LT@end@hd@ft\LT@head}
-\def\endfoot{\LT@end@hd@ft\LT@foot}
-\def\endlastfoot{\LT@end@hd@ft\LT@lastfoot}
-\def\LT@startpbox#1{%
-  \bgroup
-    \let\@footnotetext\LT@p@ftntext
-    \setlength\hsize{#1}%
-    \@arrayparboxrestore
-    \vrule \@height \ht\@arstrutbox \@width \z@}
-\def\LT@endpbox{%
-  \@finalstrut\@arstrutbox
-  \egroup
-  \the\LT@p@ftn
-  \global\LT@p@ftn{}%
-  \hfil}
-\def\LT@p@ftntext#1{%
-  \edef\@tempa{\the\LT@p@ftn\noexpand\footnotetext[\the\c@footnote]}%
-  \global\LT@p@ftn\expandafter{\@tempa{#1}}}%
-\endinput
-%%
-%% End of file `longtable.sty'.
diff --git a/etc/longtable.sty b/etc/longtable.sty
new file mode 100644 (file)
index 0000000..66e2bf9
--- /dev/null
@@ -0,0 +1,438 @@
+%%
+%% This is file `longtable.sty',
+%%
+%% Copyright 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003
+%% The LaTeX3 Project and any individual authors listed elsewhere
+%% in this file.
+%% 
+%% This file was forked from file(s) of the Standard LaTeX `Tools Bundle'.
+%% This file includes a new length LTextracouponspace which modifies
+%% the behavior of the package at the end of a page.  This feature 
+%% and package is not supported or acknowledged by Dave Carlisle.
+%% Do not contact him for such support.
+%% --------------------------------------------------------------------------
+%% 
+%% It may be distributed and/or modified under the
+%% conditions of the LaTeX Project Public License, either version 1.3
+%% of this license or (at your option) any later version.
+%% The latest version of this license is in
+%%    http://www.latex-project.org/lppl.txt
+%% and version 1.3 or later is part of all distributions of LaTeX
+%% version 2003/12/01 or later.
+%% 
+%% File: longtable.dtx Copyright (C) 1990-2001 David Carlisle
+%% File: fslongtable.sty Copyright (C) 2008 Jeff Finucane
+\NeedsTeXFormat{LaTeX2e}[1995/06/01]
+\ProvidesPackage{longtable}
+          [2004/02/01 v4.11 Multi-page Table package (DPC)]
+\def\LT@err{\PackageError{longtable}}
+\def\LT@warn{\PackageWarning{longtable}}
+\def\LT@final@warn{%
+  \AtEndDocument{%
+    \LT@warn{Table \@width s have changed. Rerun LaTeX.\@gobbletwo}}%
+  \global\let\LT@final@warn\relax}
+\DeclareOption{errorshow}{%
+  \def\LT@warn{\PackageInfo{longtable}}}
+\DeclareOption{pausing}{%
+  \def\LT@warn#1{%
+    \LT@err{#1}{This is not really an error}}}
+\DeclareOption{set}{}
+\DeclareOption{final}{}
+\ProcessOptions
+\newskip\LTleft       \LTleft=\fill
+\newskip\LTright      \LTright=\fill
+\newskip\LTpre        \LTpre=\bigskipamount
+\newskip\LTpost       \LTpost=\bigskipamount
+\newcount\LTchunksize \LTchunksize=20
+\let\c@LTchunksize\LTchunksize
+\newdimen\LTcapwidth  \LTcapwidth=4in
+\newlength\LTextracouponspace
+\newbox\LT@head
+\newbox\LT@firsthead
+\newbox\LT@foot
+\newbox\LT@lastfoot
+\newcount\LT@cols
+\newcount\LT@rows
+\newcounter{LT@tables}
+\newcounter{LT@chunks}[LT@tables]
+\ifx\c@table\undefined
+  \newcounter{table}
+  \def\fnum@table{\tablename~\thetable}
+\fi
+\ifx\tablename\undefined
+  \def\tablename{Table}
+\fi
+\newtoks\LT@p@ftn
+\mathchardef\LT@end@pen=30000
+\def\longtable{%
+  \par
+  \ifx\multicols\@undefined
+  \else
+     \ifnum\col@number>\@ne
+       \@twocolumntrue
+     \fi
+  \fi
+  \if@twocolumn
+    \LT@err{longtable not in 1-column mode}\@ehc
+  \fi
+  \begingroup
+  \@ifnextchar[\LT@array{\LT@array[x]}}
+\def\LT@array[#1]#2{%
+  \refstepcounter{table}\stepcounter{LT@tables}%
+  \if l#1%
+    \LTleft\z@ \LTright\fill
+  \else\if r#1%
+    \LTleft\fill \LTright\z@
+  \else\if c#1%
+    \LTleft\fill \LTright\fill
+  \fi\fi\fi
+  \let\LT@mcol\multicolumn
+  \let\LT@@tabarray\@tabarray
+  \let\LT@@hl\hline
+  \def\@tabarray{%
+    \let\hline\LT@@hl
+    \LT@@tabarray}%
+  \let\\\LT@tabularcr\let\tabularnewline\\%
+  \def\newpage{\noalign{\break}}%
+  \def\pagebreak{\noalign{\ifnum`}=0\fi\@testopt{\LT@no@pgbk-}4}%
+  \def\nopagebreak{\noalign{\ifnum`}=0\fi\@testopt\LT@no@pgbk4}%
+  \let\hline\LT@hline \let\kill\LT@kill\let\caption\LT@caption
+  \@tempdima\ht\strutbox
+  \let\@endpbox\LT@endpbox
+  \ifx\extrarowheight\@undefined
+    \let\@acol\@tabacol
+    \let\@classz\@tabclassz \let\@classiv\@tabclassiv
+    \def\@startpbox{\vtop\LT@startpbox}%
+    \let\@@startpbox\@startpbox
+    \let\@@endpbox\@endpbox
+    \let\LT@LL@FM@cr\@tabularcr
+  \else
+    \advance\@tempdima\extrarowheight
+    \col@sep\tabcolsep
+    \let\@startpbox\LT@startpbox\let\LT@LL@FM@cr\@arraycr
+  \fi
+  \setbox\@arstrutbox\hbox{\vrule
+    \@height \arraystretch \@tempdima
+    \@depth \arraystretch \dp \strutbox
+    \@width \z@}%
+  \let\@sharp##\let\protect\relax
+   \begingroup
+    \@mkpream{#2}%
+    \xdef\LT@bchunk{%
+       \global\advance\c@LT@chunks\@ne
+       \global\LT@rows\z@\setbox\z@\vbox\bgroup
+       \LT@setprevdepth
+       \tabskip\LTleft \noexpand\halign to\hsize\bgroup
+      \tabskip\z@ \@arstrut \@preamble \tabskip\LTright \cr}%
+  \endgroup
+  \expandafter\LT@nofcols\LT@bchunk&\LT@nofcols
+  \LT@make@row
+  \m@th\let\par\@empty
+  \everycr{}\lineskip\z@\baselineskip\z@
+  \LT@bchunk}
+\def\LT@no@pgbk#1[#2]{\penalty #1\@getpen{#2}\ifnum`{=0\fi}}
+\def\LT@start{%
+  \let\LT@start\endgraf
+  \endgraf\penalty\z@\vskip\LTpre
+  \dimen@\pagetotal
+  \advance\dimen@ \ht\ifvoid\LT@firsthead\LT@head\else\LT@firsthead\fi
+  \advance\dimen@ \dp\ifvoid\LT@firsthead\LT@head\else\LT@firsthead\fi
+  \advance\dimen@ \ht\LT@foot
+  \dimen@ii\vfuzz
+  \vfuzz\maxdimen
+    \setbox\tw@\copy\z@
+    \setbox\tw@\vsplit\tw@ to \ht\@arstrutbox
+    \setbox\tw@\vbox{\unvbox\tw@}%
+  \vfuzz\dimen@ii
+  \advance\dimen@ \ht
+        \ifdim\ht\@arstrutbox>\ht\tw@\@arstrutbox\else\tw@\fi
+  \advance\dimen@\dp
+        \ifdim\dp\@arstrutbox>\dp\tw@\@arstrutbox\else\tw@\fi
+  \advance\dimen@ -\pagegoal
+  \ifdim \dimen@>\z@\vfil\break\fi
+      \global\@colroom\@colht
+  \ifnum\thepage=1
+    \advance\vsize-\LTextracouponspace
+    \dimen@\pagegoal\advance\dimen@-\LTextracouponspace\pagegoal\dimen@
+  \fi
+  \ifvoid\LT@foot\else
+    \advance\vsize-\ht\LT@foot
+    \global\advance\@colroom-\ht\LT@foot
+    \dimen@\pagegoal\advance\dimen@-\ht\LT@foot\pagegoal\dimen@
+    \maxdepth\z@
+  \fi
+  \ifvoid\LT@firsthead\copy\LT@head\else\box\LT@firsthead\fi\nobreak
+  \output{\LT@output}}
+\def\endlongtable{%
+  \crcr
+  \noalign{%
+    \let\LT@entry\LT@entry@chop
+    \xdef\LT@save@row{\LT@save@row}}%
+  \LT@echunk
+  \LT@start
+  \unvbox\z@
+  \LT@get@widths
+  \if@filesw
+    {\let\LT@entry\LT@entry@write\immediate\write\@auxout{%
+      \gdef\expandafter\noexpand
+        \csname LT@\romannumeral\c@LT@tables\endcsname
+          {\LT@save@row}}}%
+  \fi
+  \ifx\LT@save@row\LT@@save@row
+  \else
+    \LT@warn{Column \@width s have changed\MessageBreak
+             in table \thetable}%
+    \LT@final@warn
+  \fi
+  \endgraf\penalty -\LT@end@pen
+  \endgroup
+  \global\@mparbottom\z@
+  \pagegoal\vsize
+  \endgraf\penalty\z@\addvspace\LTpost
+  \ifvoid\footins\else\insert\footins{}\fi}
+\def\LT@nofcols#1&{%
+  \futurelet\@let@token\LT@n@fcols}
+\def\LT@n@fcols{%
+  \advance\LT@cols\@ne
+  \ifx\@let@token\LT@nofcols
+    \expandafter\@gobble
+  \else
+    \expandafter\LT@nofcols
+  \fi}
+\def\LT@tabularcr{%
+  \relax\iffalse{\fi\ifnum0=`}\fi
+  \@ifstar
+    {\def\crcr{\LT@crcr\noalign{\nobreak}}\let\cr\crcr
+     \LT@t@bularcr}%
+    {\LT@t@bularcr}}
+\let\LT@crcr\crcr
+\let\LT@setprevdepth\relax
+\def\LT@t@bularcr{%
+  \global\advance\LT@rows\@ne
+  \ifnum\LT@rows=\LTchunksize
+    \gdef\LT@setprevdepth{%
+      \prevdepth\z@\global
+      \global\let\LT@setprevdepth\relax}%
+    \expandafter\LT@xtabularcr
+  \else
+    \ifnum0=`{}\fi
+    \expandafter\LT@LL@FM@cr
+  \fi}
+\def\LT@xtabularcr{%
+  \@ifnextchar[\LT@argtabularcr\LT@ntabularcr}
+\def\LT@ntabularcr{%
+  \ifnum0=`{}\fi
+  \LT@echunk
+  \LT@start
+  \unvbox\z@
+  \LT@get@widths
+  \LT@bchunk}
+\def\LT@argtabularcr[#1]{%
+  \ifnum0=`{}\fi
+  \ifdim #1>\z@
+    \unskip\@xargarraycr{#1}%
+  \else
+    \@yargarraycr{#1}%
+  \fi
+  \LT@echunk
+  \LT@start
+  \unvbox\z@
+  \LT@get@widths
+  \LT@bchunk}
+\def\LT@echunk{%
+  \crcr\LT@save@row\cr\egroup
+  \global\setbox\@ne\lastbox
+    \unskip
+  \egroup}
+\def\LT@entry#1#2{%
+  \ifhmode\@firstofone{&}\fi\omit
+  \ifnum#1=\c@LT@chunks
+  \else
+    \kern#2\relax
+  \fi}
+\def\LT@entry@chop#1#2{%
+  \noexpand\LT@entry
+    {\ifnum#1>\c@LT@chunks
+       1}{0pt%
+     \else
+       #1}{#2%
+     \fi}}
+\def\LT@entry@write{%
+  \noexpand\LT@entry^^J%
+  \@spaces}
+\def\LT@kill{%
+  \LT@echunk
+  \LT@get@widths
+  \expandafter\LT@rebox\LT@bchunk}
+\def\LT@rebox#1\bgroup{%
+  #1\bgroup
+  \unvbox\z@
+  \unskip
+  \setbox\z@\lastbox}
+\def\LT@blank@row{%
+  \xdef\LT@save@row{\expandafter\LT@build@blank
+    \romannumeral\number\LT@cols 001 }}
+\def\LT@build@blank#1{%
+  \if#1m%
+    \noexpand\LT@entry{1}{0pt}%
+    \expandafter\LT@build@blank
+  \fi}
+\def\LT@make@row{%
+  \global\expandafter\let\expandafter\LT@save@row
+    \csname LT@\romannumeral\c@LT@tables\endcsname
+  \ifx\LT@save@row\relax
+    \LT@blank@row
+  \else
+    {\let\LT@entry\or
+     \if!%
+         \ifcase\expandafter\expandafter\expandafter\LT@cols
+         \expandafter\@gobble\LT@save@row
+         \or
+         \else
+           \relax
+         \fi
+        !%
+     \else
+       \aftergroup\LT@blank@row
+     \fi}%
+  \fi}
+\let\setlongtables\relax
+\def\LT@get@widths{%
+  \setbox\tw@\hbox{%
+    \unhbox\@ne
+    \let\LT@old@row\LT@save@row
+    \global\let\LT@save@row\@empty
+    \count@\LT@cols
+    \loop
+      \unskip
+      \setbox\tw@\lastbox
+    \ifhbox\tw@
+      \LT@def@row
+      \advance\count@\m@ne
+    \repeat}%
+  \ifx\LT@@save@row\@undefined
+    \let\LT@@save@row\LT@save@row
+  \fi}
+\def\LT@def@row{%
+  \let\LT@entry\or
+  \edef\@tempa{%
+    \ifcase\expandafter\count@\LT@old@row
+    \else
+      {1}{0pt}%
+    \fi}%
+  \let\LT@entry\relax
+  \xdef\LT@save@row{%
+    \LT@entry
+    \expandafter\LT@max@sel\@tempa
+    \LT@save@row}}
+\def\LT@max@sel#1#2{%
+  {\ifdim#2=\wd\tw@
+     #1%
+   \else
+     \number\c@LT@chunks
+   \fi}%
+  {\the\wd\tw@}}
+\def\LT@hline{%
+  \noalign{\ifnum0=`}\fi
+    \penalty\@M
+    \futurelet\@let@token\LT@@hline}
+\def\LT@@hline{%
+  \ifx\@let@token\hline
+    \global\let\@gtempa\@gobble
+    \gdef\LT@sep{\penalty-\@medpenalty\vskip\doublerulesep}%
+  \else
+    \global\let\@gtempa\@empty
+    \gdef\LT@sep{\penalty-\@lowpenalty\vskip-\arrayrulewidth}%
+  \fi
+  \ifnum0=`{\fi}%
+  \multispan\LT@cols
+     \unskip\leaders\hrule\@height\arrayrulewidth\hfill\cr
+  \noalign{\LT@sep}%
+  \multispan\LT@cols
+     \unskip\leaders\hrule\@height\arrayrulewidth\hfill\cr
+  \noalign{\penalty\@M}%
+  \@gtempa}
+\def\LT@caption{%
+  \noalign\bgroup
+    \@ifnextchar[{\egroup\LT@c@ption\@firstofone}\LT@capti@n}
+\def\LT@c@ption#1[#2]#3{%
+  \LT@makecaption#1\fnum@table{#3}%
+  \def\@tempa{#2}%
+  \ifx\@tempa\@empty\else
+     {\let\\\space
+     \addcontentsline{lot}{table}{\protect\numberline{\thetable}{#2}}}%
+  \fi}
+\def\LT@capti@n{%
+  \@ifstar
+    {\egroup\LT@c@ption\@gobble[]}%
+    {\egroup\@xdblarg{\LT@c@ption\@firstofone}}}
+\def\LT@makecaption#1#2#3{%
+  \LT@mcol\LT@cols c{\hbox to\z@{\hss\parbox[t]\LTcapwidth{%
+    \sbox\@tempboxa{#1{#2: }#3}%
+    \ifdim\wd\@tempboxa>\hsize
+      #1{#2: }#3%
+    \else
+      \hbox to\hsize{\hfil\box\@tempboxa\hfil}%
+    \fi
+    \endgraf\vskip\baselineskip}%
+  \hss}}}
+\def\LT@output{%
+  \ifnum\outputpenalty <-\@Mi
+    \ifnum\outputpenalty > -\LT@end@pen
+      \LT@err{floats and marginpars not allowed in a longtable}\@ehc
+    \else
+      \setbox\z@\vbox{\unvbox\@cclv}%
+      \ifdim \ht\LT@lastfoot>\ht\LT@foot
+        \dimen@\pagegoal
+        \advance\dimen@-\ht\LT@lastfoot
+        \ifdim\dimen@<\ht\z@
+          \setbox\@cclv\vbox{\unvbox\z@\copy\LT@foot\vss}%
+          \@makecol
+          \@outputpage
+          \setbox\z@\vbox{\box\LT@head}%
+        \fi
+      \fi
+      \global\@colroom\@colht
+      \global\vsize\@colht
+      \vbox
+        {\unvbox\z@\box\ifvoid\LT@lastfoot\LT@foot\else\LT@lastfoot\fi}%
+    \fi
+  \else
+    \setbox\@cclv\vbox{\unvbox\@cclv\copy\LT@foot\vss}%
+    \@makecol
+    \@outputpage
+      \global\vsize\@colroom
+    \copy\LT@head\nobreak
+  \fi}
+\def\LT@end@hd@ft#1{%
+  \LT@echunk
+  \ifx\LT@start\endgraf
+    \LT@err
+     {Longtable head or foot not at start of table}%
+     {Increase LTchunksize}%
+  \fi
+  \setbox#1\box\z@
+  \LT@get@widths
+  \LT@bchunk}
+\def\endfirsthead{\LT@end@hd@ft\LT@firsthead}
+\def\endhead{\LT@end@hd@ft\LT@head}
+\def\endfoot{\LT@end@hd@ft\LT@foot}
+\def\endlastfoot{\LT@end@hd@ft\LT@lastfoot}
+\def\LT@startpbox#1{%
+  \bgroup
+    \let\@footnotetext\LT@p@ftntext
+    \setlength\hsize{#1}%
+    \@arrayparboxrestore
+    \vrule \@height \ht\@arstrutbox \@width \z@}
+\def\LT@endpbox{%
+  \@finalstrut\@arstrutbox
+  \egroup
+  \the\LT@p@ftn
+  \global\LT@p@ftn{}%
+  \hfil}
+\def\LT@p@ftntext#1{%
+  \edef\@tempa{\the\LT@p@ftn\noexpand\footnotetext[\the\c@footnote]}%
+  \global\LT@p@ftn\expandafter{\@tempa{#1}}}%
+\endinput
+%%
+%% End of file `longtable.sty'.
index e73012f..bedb5ec 100755 (executable)
@@ -11,7 +11,7 @@ perl Makefile.PL && make && make install
 cd ..
 
 #( cd ..; make deploy; cd fs_selfservice )
-( cd ..; make clean; make install-perl-modules; /etc/init.d/freeside restart; cd fs_selfservice )
+( cd ..; make clean; make configure-rt; make install-perl-modules; /etc/init.d/freeside restart; cd fs_selfservice )
 
 #cp /home/ivan/freeside/fs_selfservice/FS-SelfService/cgi/* /var/www/MyAccount
 #chown freeside /var/www/MyAccount/*.cgi
old mode 100644 (file)
new mode 100755 (executable)
index bec37ca..defd4a5 100644 (file)
@@ -9,29 +9,15 @@
   <TH ALIGN="right">Amount&nbsp;Due</TH>
   <TD COLSPAN=7>
     <TABLE><TR><TD BGCOLOR="#ffffff">
-      $<%=sprintf("%.2f",$balance)%>
-    </TD></TR></TABLE>
-  </TD>
-</TR>
-<TR>
-  <TH ALIGN="right">Payment&nbsp;amount</TH>
-  <TD COLSPAN=7>
-    <TABLE><TR><TD BGCOLOR="#ffffff">
-<%=
-    $amt = '';
-    if ( $balance > 0 ) {
-        $amt = $balance;
-        $amt += $amt * $credit_card_surcharge_percentage/100
-                                    if $credit_card_surcharge_percentage > 0;
-        $amt = sprintf("%.2f",$amt);
-    }
-    '';
-%>
-      $<INPUT TYPE="text" NAME="amount" SIZE=8 VALUE="<%=$amt%>">
+      <FONT COLOR="#000000">$<%=sprintf("%.2f",$balance)%></FONT>
     </TD></TR></TABLE>
   </TD>
 </TR>
+
+<%= $tr_amount_fee %>
+
 <%= include('discount_term') %>
+
 <TR>
   <TH ALIGN="right">Card&nbsp;type</TH>
   <TD COLSPAN=7>
old mode 100644 (file)
new mode 100755 (executable)
index fe8d082..61361b8
@@ -476,7 +476,21 @@ sub process_order_recharge {
 }
 
 sub make_payment {
-  payment_info( 'session_id' => $session_id );
+
+  my $payment_info = payment_info( 'session_id' => $session_id );
+
+  my $tr_amount_fee = mason_comp(
+    'session_id' => $session_id,
+    'comp'       => '/elements/tr-amount_fee.html',
+    'args'       => [ 'amount' => $payment_info->{'balance'},
+                    ],
+  );
+
+  $tr_amount_fee = $tr_amount_fee->{'error'} || $tr_amount_fee->{'output'};
+
+  $payment_info->{'tr_amount_fee'} = $tr_amount_fee;
+
+  $payment_info;
 }
 
 sub payment_results {
old mode 100644 (file)
new mode 100755 (executable)
index cea3661..3c68e83 100644 (file)
@@ -5,6 +5,24 @@ package HTML::Mason;
 use strict;
 use warnings;
 use FS::Mason qw( mason_interps );
+use FS::Trace;
+
+if ( %%%RT_ENABLED%%% ) {
+
+  require RT;
+
+  $> = scalar(getpwnam('freeside'));
+
+  RT::LoadConfig();
+  RT::Init();
+
+  # disconnect DB before fork:
+  #   (avoid 'prepared statement "dbdpg_p\d+_\d+" already exists' errors?)
+  $RT::Handle->dbh(undef);
+  undef $RT::Handle;
+
+  $> = $<;
+}
 
 #use vars qw($r);
 
@@ -38,6 +56,8 @@ sub handler
     #($r) = @_;
     my $r = shift;
 
+    FS::Trace->log('protecting fds');
+
     #from rt/bin/webmux.pl(.in)
     if ( !$protect_fds && $ENV{'MOD_PERL'} && exists $ENV{'MOD_PERL_API_VERSION'}
         && $ENV{'MOD_PERL_API_VERSION'} >= 2
@@ -63,6 +83,8 @@ sub handler
 
     ###Module::Refresh->refresh;###
 
+    FS::Trace->log('setting content_type / headers');
+
     $r->content_type('text/html; charset=utf-8');
     #$r->content_type('text/html; charset=iso-8859-1');
     #eorar
@@ -76,6 +98,8 @@ sub handler
 
     if ( $r->filename =~ /\/rt\// ) { #RT
 
+      FS::Trace->log('handling RT file');
+
       # We don't need to handle non-text, non-xml items
       return -1 if defined( $r->content_type )
                 && $r->content_type !~ m!(^text/|\bxml\b)!io;
@@ -84,15 +108,20 @@ sub handler
       local $SIG{__WARN__};
       local $SIG{__DIE__};
 
+      FS::Trace->log('initializing RT');
       my_rt_init();
 
+      FS::Trace->log('setting RT interpreter');
       $ah->interp($rt_interp);
 
     } else {
 
+      FS::Trace->log('handling Freeside file');
+
       local $SIG{__WARN__};
       local $SIG{__DIE__};
 
+      FS::Trace->log('initializing RT');
       my_rt_init();
 
       #we don't want the RT error handlers under FS
@@ -102,10 +131,12 @@ sub handler
         undef($SIG{__DIE__})  if defined($SIG{__DIE__} );
       }
 
+      FS::Trace->log('setting Freeside interpreter');
       $ah->interp($fs_interp);
 
     }
 
+    FS::Trace->log('handling request');
     my %session;
     my $status;
     eval { $status = $ah->handle_request($r); };
@@ -125,22 +156,22 @@ sub handler
 #       );
 #    }
 
+    FS::Trace->log('done');
+
+    FS::Trace->dumpfile( "%%%FREESIDE_EXPORT%%%/profile/$$.".time,
+                         FS::Trace->total. ' '. $r->filename
+                       )
+      if FS::Trace->total > 5; #10?
+
+    FS::Trace->reset;
+
     $status;
 }
 
-my $rt_initialized = 0;
-
 sub my_rt_init {
   return unless $RT::VERSION;
-
-  if ( $rt_initialized ) {
-    RT::ConnectToDatabase();
-    RT::InitSignalHandlers();
-  } else {
-    RT::LoadConfig();
-    RT::Init();
-    $rt_initialized++;
-  }
+  RT::ConnectToDatabase();
+  RT::InitSignalHandlers();
 }
 
 1;
index 64288b8..fc9ce54 100755 (executable)
@@ -25,6 +25,7 @@ full offerings (via their type).<BR><BR>
   <TH CLASS="grid" BGCOLOR="#cccccc" COLSPAN=<% ( $cgi->param('showdisabled') || !dbdef->table('agent')->column('disabled') ) ? 2 : 3 %>>Agent</TH>
   <TH CLASS="grid" BGCOLOR="#cccccc">Type</TH>
   <TH CLASS="grid" BGCOLOR="#cccccc">Master Customer</TH>
+  <TH CLASS="grid" BGCOLOR="#cccccc">Commissions</TH>
   <TH CLASS="grid" BGCOLOR="#cccccc">Access Groups</TH>
   <TH CLASS="grid" BGCOLOR="#cccccc"><FONT SIZE=-1>Invoice<BR>Template</FONT></TH>
   <TH CLASS="grid" BGCOLOR="#cccccc">Customers</TH>
@@ -93,6 +94,33 @@ full offerings (via their type).<BR><BR>
         </TD>
 
         <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+
+          <TABLE>
+
+%           #surprising amount of false laziness w/ edit/process/agent.cgi
+%           my @pkg_class = qsearch('pkg_class', { 'disabled'=>'' });
+%           foreach my $pkg_class ( '', @pkg_class ) {
+%             my %agent_pkg_class = ( 'agentnum' => $agent->agentnum,
+%                                     'classnum' => $pkg_class ? $pkg_class->classnum : ''
+%                                   );
+%             my $agent_pkg_class =
+%               qsearchs( 'agent_pkg_class', \%agent_pkg_class )
+%               || new FS::agent_pkg_class   \%agent_pkg_class;
+%             my $param = 'classnum'. $agent_pkg_class{classnum};
+
+              <TR>
+                <TD><% $agent_pkg_class->commission_percent || 0 %>%</TD>
+                <TD><% $pkg_class ? $pkg_class->classname : mt('(no package class)') |h %>
+                </TD>
+              </TR>
+
+%           }
+
+          </TABLE>
+
+        </TD>
+
+        <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
 %         foreach my $access_group (
 %           map $_->access_group,
 %               qsearch('access_groupagent', { 'agentnum' => $agent->agentnum })
index f5d450b..7928199 100644 (file)
@@ -3,7 +3,7 @@
                  'html_init'   => $html_init,
                  'name'        => 'customer note classes',
                  'disableable' => 1,
-                 'disabled_statuspos' => 2,
+                 'disabled_statuspos' => 1,
                  'query'       => { 'table'     => 'cust_note_class',
                                     'hashref'   => {},
                                     'order_by' => 'ORDER BY classnum',
index 8e28f4f..b7ecc00 100755 (executable)
@@ -36,10 +36,9 @@ function part_export_areyousure(href) {
       <TD CLASS="grid" BGCOLOR="<% $bgcolor %>"><A HREF="<% $p %>edit/part_export.cgi?<% $part_export->exportnum %>"><% $part_export->exportnum %></A></TD>
 
       <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
-% if( $part_export->exportname ) {
-  <B><% $part_export->exportname %>:</B><BR>
-% }
-<% $part_export->exporttype %> to <% $part_export->machine %> (<A HREF="<% $p %>edit/part_export.cgi?<% $part_export->exportnum %>">edit</A>&nbsp;|&nbsp;<A HREF="javascript:part_export_areyousure('<% $p %>misc/delete-part_export.cgi?<% $part_export->exportnum %>')">delete</A>)</TD>
+        <% $part_export->label_html %>
+        (<A HREF="<% $p %>edit/part_export.cgi?<% $part_export->exportnum %>">edit</A>&nbsp;|&nbsp;<A HREF="javascript:part_export_areyousure('<% $p %>misc/delete-part_export.cgi?<% $part_export->exportnum %>')">delete</A>)
+      </TD>
 
       <TD CLASS="inv" BGCOLOR="<% $bgcolor %>">
         <% itable() %>
index 26d090a..a8f4a7c 100755 (executable)
@@ -141,16 +141,7 @@ function part_export_areyousure(href) {
 %
 
         <TR>
-          <TD><A HREF="<% $p %>edit/part_export.cgi?<% $part_export->exportnum %>">
-<% $part_export->exportnum %>:&nbsp;
-% if ($part_export->exportname) {
-<B><% $part_export->exportname %></B> (
-% }
-<% $part_export->exporttype %>&nbsp;to&nbsp;<% $part_export->machine %>
-% if ($part_export->exportname) {
-)
-% }
-</A></TD>
+          <TD><A HREF="<% $p %>edit/part_export.cgi?<% $part_export->exportnum %>"><% $part_export->label_html %></A></TD>
        </TR>
 %  } 
 
index fbf6d37..98e81ab 100644 (file)
@@ -5,15 +5,26 @@
   'query'       => { 'table' => 'radius_group' },
   'count_query' => 'SELECT COUNT(*) FROM radius_group',
   'header'      => [ '#', 'RADIUS Group', 'Description', 'Priority',
-                     'Check', 'Reply' ],
+                     'Check', 'Reply', 'Speed' ],
   'fields'      => [ 'groupnum',
                      'groupname',
                      'description',
                      'priority',
-                     $check_attr, $reply_attr
+                     $check_attr, $reply_attr,
+                     sub { 
+                      my $group = shift;
+                      if ($group->speed_down and $group->speed_up) {
+                        return join (' / ', $group->speed_down, $group->speed_up);
+                      } elsif ( $group->speed_down ) {
+                        return $group->speed_down . ' down';
+                      } elsif ( $group->speed_up ) {
+                        return $group->speed_up . ' up';
+                      }
+                      '';
+                     },
                    ],
-  'align'       => 'lllcll',
-  'links'       => [ $link, $link, '', '', '', '',
+  'align'       => 'lllcllc',
+  'links'       => [ $link, $link, '', '', '', '', ''
                    ],
 &>
 <%init>
index fe285be..14e97bf 100644 (file)
                  'header'      => [ '#',
                                     ucfirst($classname) . ' Reason Type',
                                     ucfirst($classname) . ' Reason',
+                                    ($class eq 'S' ?  'Unsuspension Fee' : ()),
                                   ],
                  'fields'      => [ 'reasonnum',
                                     sub { shift->reasontype->type },
                                     'reason',
+                                    $unsuspend_pkg_comment,
                                   ],
                  'links'       => [ $link,
                                     $link,
                                     '',
+                                    $unsuspend_pkg_link,
                                   ],
              )
 %>
@@ -50,4 +53,18 @@ my $count_query = 'SELECT COUNT(*) FROM reason LEFT JOIN reason_type on ' .
 
 my $link = [ $p."edit/reason.html?class=$class&reasonnum=", 'reasonnum' ];
 
+my ($unsuspend_pkg_comment, $unsuspend_pkg_link);
+if ( $class eq 'S' ) {
+  $unsuspend_pkg_comment = sub {
+    my $pkgpart = shift->unsuspend_pkgpart or return '';
+    my $part_pkg = FS::part_pkg->by_key($pkgpart) or return '';
+    $part_pkg->pkg_comment;
+  };
+
+  my $unsuspend_pkg_link = sub {
+    my $pkgpart = shift->unsuspend_pkgpart or return '';
+    [ $p."edit/part_pkg.cgi?", $pkgpart ];
+  };
+}
+
 </%init>
index a4f9890..7960d7e 100644 (file)
@@ -304,7 +304,6 @@ Setting <b><% $key %></b>
 %
 %     my %opt = ( 'element_name' => "$key$n",
 %                 'empty_label'  => ' ',
-%                 'showdisabled' => 1,
 %               );
 %     if ( $config_item->multiple ) {
 %       $opt{'multiple'} = 1 if $config_item->multiple;
index fab8cd0..e40b243 100644 (file)
@@ -6,7 +6,7 @@
 
 <P>
 
-Copyright &copy; 2005-2009 Freeside Internet Services, Inc.<BR>
+Copyright &copy; 2005-2012 Freeside Internet Services, Inc.<BR>
 Copyright &copy; 2000-2005 Ivan Kohler<BR>
 Copyright &copy; 1999 Silicon Interactive Software Design<BR>
 All rights reserved<BR>
index 6707d66..b043d1e 100755 (executable)
 </SCRIPT>
 
 <INPUT TYPE="hidden" NAME="agentnum" VALUE="<% $agent->agentnum %>">
-Agent #<% $agent->agentnum ? $agent->agentnum : "(NEW)" %>
 
-<% &ntable("#cccccc", 2, '') %>
+<FONT CLASS="fsinnerbox-title">
+  Agent #<% $agent->agentnum ? $agent->agentnum : "(NEW)" %>
+</FONT>
+
+<TABLE CLASS="fsinnerbox">
 
   <TR>
     <TH ALIGN="right">Agent</TH>
@@ -117,8 +120,13 @@ Agent #<% $agent->agentnum ? $agent->agentnum : "(NEW)" %>
     </TR>
 % } 
 
+</TABLE>
+<BR>
+
+<FONT CLASS="fsinnerbox-title"><% mt('Access Groups') |h %></FONT>
+<TABLE CLASS="fsinnerbox">
+
   <TR>
-    <TD ALIGN="right">Access Groups</TD>
     <TD><% include('/elements/checkboxes-table.html',
                      'source_obj'   => $agent,
                      'link_table'   => 'access_groupagent',
@@ -131,6 +139,38 @@ Agent #<% $agent->agentnum ? $agent->agentnum : "(NEW)" %>
   </TR>
 
 </TABLE>
+<BR>
+
+<FONT CLASS="fsinnerbox-title"><% mt('Commissions') |h %></FONT>
+<TABLE CLASS="fsinnerbox">
+
+% #surprising amount of false laziness w/ edit/process/agent.cgi
+% my @pkg_class = qsearch('pkg_class', { 'disabled'=>'' });
+% foreach my $pkg_class ( '', @pkg_class ) {
+%   my %agent_pkg_class = ( 'agentnum' => $agent->agentnum,
+%                           'classnum' => $pkg_class ? $pkg_class->classnum : ''
+%                         );
+%   my $agent_pkg_class =
+%     qsearchs( 'agent_pkg_class', \%agent_pkg_class )
+%     || new FS::agent_pkg_class   \%agent_pkg_class;
+%   my $param = 'classnum'. $agent_pkg_class{classnum};
+
+    <TR>
+      <TD><INPUT TYPE      = "text"
+                 NAME      = "<% $param %>"
+                 VALUE     = "<% $cgi->param($param) || $agent_pkg_class->commission_percent |h %>"
+                 SIZE      = 6
+                 MAXLENGTH = 7
+          >%
+      </TD>
+      <TD><% $pkg_class ? $pkg_class->classname : mt('(no package class)') |h %>
+      </TD>
+    </TR>
+
+% }
+
+</TABLE>
+
 
 <BR>
 <INPUT TYPE="submit" VALUE="<% $agent->agentnum ? "Apply changes" : "Add agent" %>">
index ef81eba..e3e812f 100755 (executable)
 <& cust_main/top_misc.html, $cust_main, 'custnum' => $custnum  &>
 
 %# birthdate
-% if (    $conf->exists('cust_main-enable_birthdate')
+% if (    $conf->config('national_id-country')
+%      || $conf->exists('cust_main-enable_birthdate')
 %      || $conf->exists('cust_main-enable_spouse_birthdate')
+%      || $conf->exists('cust_main-enable_anniversary_date')
 %    )
 % {
   <BR>
@@ -51,6 +53,7 @@
     <& /elements/location.html,
         object => $cust_main->bill_location,
         prefix => 'bill_',
+        enable_coords => 1,
     &>
     <& cust_main/after_bill_location.html, $cust_main &>
     </TABLE>
@@ -75,6 +78,7 @@
         prefix => 'ship_',
         enable_censustract => 1,
         enable_district => 1,
+        enable_coords => 1,
     &>
     </TABLE>
     <TABLE CLASS="fsinnerbox" ID="table_ship_location_blank"
@@ -246,6 +250,8 @@ if ( $cgi->param('error') ) {
   $stateid = $cust_main->stateid; # don't mask an entered value on errors
   $payinfo = $cust_main->payinfo; # don't mask an entered value on errors
 
+  $cust_main->national_id( $cgi->param('national_id1') || $cgi->param('national_id2') );
+
   $prospectnum = $cgi->param('prospectnum') || '';
 
   $pkgpart_svcpart = $cgi->param('pkgpart_svcpart') || '';
index d7082f2..2925ca8 100644 (file)
     </TR>
 % }
 
+% if ( $conf->exists('cust_main-select-prorate_day') ) {
+    <TR>
+      <TD ALIGN="right" WIDTH="200"><% mt('Prorate day (1-28)') |h %> </TD>
+      <TD>
+        <INPUT TYPE="text" NAME="prorate_day" VALUE="<% $cust_main->prorate_day %>" SIZE=3 MAXLENGTH=2>
+      </TD>
+    </TR>
+% } else {
+    <INPUT TYPE="hidden" NAME="prorate_day" VALUE="<% $cust_main->prorate_day %>">
+% }
+
     <TR>
       <TD ALIGN="right" WIDTH="200"><% mt('Invoice terms') |h %> </TD>
       <TD WIDTH="408">
index 5d6a123..e1adbd3 100644 (file)
@@ -1,5 +1,38 @@
 <% ntable("#cccccc", 2) %>
+
 % # maybe put after the contact names?
+
+% my $id_country = $conf->config('national_id-country');
+%  if ( $id_country ) {
+%   if ( $id_country eq 'MY' ) {
+%     my($old, $nric) = ( '', '');
+%     if ( $cust_main->national_id =~ /^\d{6}\-\d{2}\-\d{4}$/ ) {
+%       $nric = $cust_main->national_id;
+%     } else { # elsif ( $cust_main->national_id =~ /^\w\d{9}$/ ) {
+%       $old = $cust_main->national_id;
+%     #} else {
+%     #  warn "unknown national_id format";
+%#       <INPUT TYPE="hidden" NAME="national_id0" VALUE="<% $cust_main->national_id |h %>">
+%     }
+
+      <% include( '/elements/tr-input-text.html',
+                    'field' => 'national_id1',
+                    'value' => $nric,
+                    'label' => 'NRIC',
+                )
+      %>
+      <% include( '/elements/tr-input-text.html',
+                    'field' => 'national_id2',
+                    'value' => $old,
+                    'label' => 'Old IC/Passport',
+                )
+      %>
+
+%   } else {
+%     warn "unknown national_id-country $id_country";
+%   }
+% }
+
 % if ( $conf->exists('cust_main-enable_birthdate') ) {
   <% include( '/elements/tr-input-date-field.html', {
                 'name'        => 'birthdate',
@@ -11,6 +44,7 @@
             })
   %>
 % }
+
 % if ( $conf->exists('cust_main-enable_spouse_birthdate') ) {
   <% include( '/elements/tr-input-date-field.html', {
                 'name'        => 'spouse_birthdate',
             })
   %>
 % }
+
+% if ( $conf->exists('cust_main-enable_anniversary_date') ) {
+  <% include( '/elements/tr-input-date-field.html', {
+                'name'        => 'anniversary_date',
+                'value'       => $cust_main->anniversary_date,
+                'label'       => 'Anniversary Date',
+                'format'      => ( $conf->config('date_format') || "%m/%d/%Y" ),
+                'usedatetime' => 1,
+                'noinit'      => $noinit++,
+            })
+  %>
+% }
+
 </TABLE>
 <%init>
 
index ba93040..1ef69fd 100755 (executable)
@@ -141,7 +141,7 @@ my $reason  = $cgi->param('reason');
 my $link    = $cgi->param('popup') ? 'popup' : '';
 
 my @rights = ();
-push @rights, 'Post refund'                if $payby =~ /^(BILL|CASH)$/;
+push @rights, 'Post refund'                if $payby =~ /^(BILL|CASH|MCRD)$/;
 push @rights, 'Post check refund'          if $payby eq 'BILL';
 push @rights, 'Post cash refund '          if $payby eq 'CASH';
 push @rights, 'Refund payment'             if $payby =~ /^(CARD|CHEK)$/;
index b195eb3..9bcd1e7 100644 (file)
@@ -22,6 +22,7 @@
                                  postfix => '<BR><FONT SIZE="-1"><I>(blank for non-expiring discount)</I></FONT>',
                                },
                                { field => 'setup', type => 'checkbox', value=>'Y', },
+                               #{ field => 'linked', type => 'checkbox', value=>'Y', },
                              ],
                  'labels' => { 
                                'discountnum' => 'Discount #',
@@ -32,6 +33,7 @@
                                'percent'     => 'Percentage&nbsp;',
                                'months'      => 'Duration (months)',
                                'setup'       => 'Apply to setup fees',
+                               #'linked'      => 'Apply to add-on packages',
                              },
                  'viewall_dir' => 'browse',
                  'new_callback' => $new_callback,
@@ -114,6 +116,10 @@ my $javascript = <<END;
         document.getElementById('percent_label').style.visibility = 'hidden';
         document.getElementById('percent_input0').style.display = 'none';
         document.getElementById('percent_input0').style.visibility = 'hidden';
+//        document.getElementById('linked_label').style.display = 'none';
+//        document.getElementById('linked_label').style.visibility = 'hidden';
+//        document.getElementById('linked').style.display = 'none';
+//        document.getElementById('linked').style.visibility = 'hidden';
       } else if ( _type == 'Amount' ) {
         document.getElementById('amount_label').style.display = '';
         document.getElementById('amount_label').style.visibility = '';
@@ -123,6 +129,10 @@ my $javascript = <<END;
         document.getElementById('percent_label').style.visibility = 'hidden';
         document.getElementById('percent_input0').style.display = 'none';
         document.getElementById('percent_input0').style.visibility = 'hidden';
+//        document.getElementById('linked_label').style.display = 'none';
+//        document.getElementById('linked_label').style.visibility = 'hidden';
+//        document.getElementById('linked').style.display = 'none';
+//        document.getElementById('linked').style.visibility = 'hidden';
       } else if ( _type == 'Percentage' ) {
         document.getElementById('amount_label').style.display = 'none';
         document.getElementById('amount_label').style.visibility = 'hidden';
@@ -132,6 +142,10 @@ my $javascript = <<END;
         document.getElementById('percent_label').style.visibility = '';
         document.getElementById('percent_input0').style.display = '';
         document.getElementById('percent_input0').style.visibility = '';
+//        document.getElementById('linked_label').style.display = '';
+//        document.getElementById('linked_label').style.visibility = '';
+//        document.getElementById('linked').style.display = '';
+//        document.getElementById('linked').style.visibility = '';
      }
 
     }
index 2e66fc3..8e6232c 100644 (file)
@@ -49,7 +49,7 @@ sub html_bottom {
             'link_table'    => 'export_nas',
             'target_table'  => 'part_export',
             'hashref'       => { 'exporttype' => 
-                                  { op => 'LIKE', value => '%sqlradius' }
+                                  { op => 'LIKE', value => '%sqlradius%' }
                                 },
             'name_callback' => sub { $_[0]->label },
             'default'       => 'yes',
index d7219b7..0407ee7 100644 (file)
   </TD>
 </TR>
 <TR>
-  <TD ALIGN="right">Export host</TD>
-  <TD>
-    <INPUT TYPE="text" NAME="machine" VALUE="<% $part_export->machine %>">
-  </TD>
-</TR>
-<TR>
   <TD ALIGN="right">Export</TD>
   <TD><% $widget->html %>
 
@@ -63,7 +57,7 @@ my $widget = new HTML::Widgets::SelectLayers(
   'options'        => \%layers,
   'form_name'      => 'dummy',
   'form_action'    => 'process/part_export.cgi',
-  'form_text'      => [qw( exportnum exportname machine )],
+  'form_text'      => [qw( exportnum exportname )],
 #  'form_checkbox'  => [qw()],
   'html_between'    => "</TD></TR></TABLE>\n",
   'layer_callback'  => sub {
@@ -71,9 +65,69 @@ my $widget = new HTML::Widgets::SelectLayers(
     my $html = qq!<INPUT TYPE="hidden" NAME="exporttype" VALUE="$layer">!.
                ntable("#cccccc",2);
 
-    $html .= '<TR><TD ALIGN="right">Description</TD><TD BGCOLOR=#ffffff>'.
-             $exports->{$layer}{notes}. '</TD></TR>'
-      if $layer;
+    if ( $layer ) {
+      $html .= '<TR><TD ALIGN="right">Description</TD><TD BGCOLOR=#ffffff>'.
+               $exports->{$layer}{notes}. '</TD></TR>';
+
+      if ( $exports->{$layer}{no_machine} ) {
+        $html .= '<INPUT TYPE="hidden" NAME="machine" VALUE="">'.
+                 '<INPUT TYPE="hidden" NAME="svc_machine" VALUE=N">';
+      } else {
+        $html .= '<TR><TD ALIGN="right">Hostname or IP</TD><TD>';
+        my $machine = $part_export->machine;
+        if ( $exports->{$layer}{svc_machine} ) {
+          my( $N_CHK, $Y_CHK) = ( 'CHECKED', '' );
+          my( $machine_DISABLED, $pem_DISABLED) = ( '', 'DISABLED' );
+          my $part_export_machine = '';
+          if ( $cgi->param('svc_machine') eq 'Y'
+                 || $machine eq '_SVC_MACHINE'
+             )
+          {
+            $Y_CHK = 'CHECKED';
+            $N_CHK = 'CHECKED';
+            $machine_DISABLED = 'DISABLED';
+            $pem_DISABLED = '';
+            $machine = '';
+            $part_export_machine =
+              $cgi->param('part_export_machine')
+              || join "\n",
+                   map $_->machine,
+                     grep ! $_->disabled,
+                       $part_export->part_export_machine;
+          }
+          my $oc = qq(onChange="${layer}_svc_machine_changed(this)");
+          $html .= qq[
+            <INPUT TYPE="radio" NAME="svc_machine" VALUE="N" $N_CHK $oc>
+            <INPUT TYPE="text" NAME="machine" ID="${layer}_machine" VALUE="$machine" $machine_DISABLED>
+            <BR>
+            <INPUT TYPE="radio" NAME="svc_machine" VALUE="Y" $Y_CHK $oc>
+            Selected in each customer service from these choices
+            <TEXTAREA NAME="part_export_machine" ID="${layer}_part_export_machine" $pem_DISABLED>$part_export_machine</TEXTAREA>
+
+            <SCRIPT TYPE="text/javascript">
+              function ${layer}_svc_machine_changed (what) {
+                if ( what.checked ) {
+                  var machine = document.getElementById("${layer}_machine");
+                  var part_export_machine = document.getElementById("${layer}_part_export_machine");
+                  if ( what.value == 'Y' ) {
+                    machine.disabled = true;
+                    part_export_machine.disabled = false;
+                  } else if ( what.value == 'N' ) {
+                    machine.disabled = false;
+                    part_export_machine.disabled = true;
+                  }
+                }
+              }
+            </SCRIPT>
+          ];
+        } else {
+          $html .= qq(<INPUT TYPE="text" NAME="machine" VALUE="$machine">).
+                     '<INPUT TYPE="hidden" NAME="svc_machine" VALUE=N">';
+        }
+        $html .= "</TD></TR>";
+      }
+
+    }
 
     foreach my $option ( keys %{$exports->{$layer}{options}} ) {
       my $optinfo = $exports->{$layer}{options}{$option};
index cd07313..f3ad8f5 100755 (executable)
@@ -55,6 +55,7 @@
                             'svc_dst_pkgpart'  => 'Include services of package',
                             'report_option'    => 'Report classes',
                             'fcc_ds0s'         => 'Voice-grade equivalents',
+                            'fcc_voip_class'   => 'Category',
                           },
 
               'fields' => [
                                     { type  => 'tablebreak-tr-title',
                                       value => 'FCC Form 477 information',
                                     },
+                                    { field=>'fcc_voip_class',
+                                      type=>'select-voip_class',
+                                    },
                                     { field=>'fcc_ds0s', type=>'text', size=>6 },
                                   )
                                  : ()
index 4bd0837..007c246 100755 (executable)
 %              && qsearchs( 'export_svc', {
 %                                   exportnum => $part_export->exportnum,
 %                                   svcpart   => $clone || $part_svc->svcpart });
-%        $html .= '>'.$part_export->exportnum. ': ';
-%        $html .= $part_export->exportname . '<DIV ALIGN="right"><FONT SIZE=-1>'
-%          if ( $part_export->exportname );
-%        $html .= $part_export->exporttype. ' to '. $part_export->machine;
-%        $html .= '</FONT></DIV>' if ( $part_export->exportname );
-%        $html .= '</TD>';
+%        $html .= '>'. $part_export->label_html. '</TD>';
 %        $count++;
 %        $html .= '</TR><TR>' unless $count % $columns;
 %      }
index e5897b0..dfe52f1 100644 (file)
@@ -91,6 +91,7 @@ my %modules =  (
 
   'KeyBank'               => 'Business::BatchPayment',
   'Paymentech'            => 'Business::BatchPayment',
+  'TD_EFT'                => 'Business::BatchPayment',
 );
 
 my %modules_for_namespace;
@@ -141,7 +142,7 @@ my $fields = [
                {
                  field               => 'gateway_options',
                  type                => 'textarea',
-                 rows                => '8',
+                 rows                => '12',
                  cols                => '40', 
                  curr_value_callback => sub { my($cgi, $object, $fref) = @_;
                                               join("\r", $object->options );
index c03bbf9..3f0d6ba 100644 (file)
@@ -22,6 +22,7 @@ characters each
 
 <& /elements/select-agent.html,
      'empty_label' => '(any agent)',
+     'curr_value'  => $agentnum,
 &>
 
 <TABLE>
index e776d28..034c4cc 100755 (executable)
@@ -1,11 +1,12 @@
 <% include( 'elements/process.html',
-              'table'       => 'agent',
-              'viewall_dir' => 'browse',
-              'viewall_ext' => 'cgi',
-              'process_m2m' => { 'link_table'   => 'access_groupagent',
-                                 'target_table' => 'access_group',
-                               },
-              'edit_ext'    => 'cgi',
+              'table'            => 'agent',
+              'viewall_dir'      => 'browse',
+              'viewall_ext'      => 'cgi',
+              'process_m2m'      => { 'link_table'   => 'access_groupagent',
+                                      'target_table' => 'access_group',
+                                    },
+              'edit_ext'         => 'cgi',
+              'noerror_callback' => $process_agent_pkg_class,
           )
 %>
 <%init>
@@ -18,4 +19,30 @@ if ( FS::Conf->new->exists('disable_acl_changes') ) {
   die "shouldn't be reached";
 }
 
+my $process_agent_pkg_class = sub {
+  my( $cgi, $agent ) = @_;
+
+  #surprising amount of false laziness w/ edit/agent.cgi
+  my @pkg_class = qsearch('pkg_class', { 'disabled'=>'' });
+  foreach my $pkg_class ( '', @pkg_class ) {
+    my %agent_pkg_class = ( 'agentnum' => $agent->agentnum,
+                            'classnum' => $pkg_class ? $pkg_class->classnum : ''
+                          );
+    my $agent_pkg_class =
+      qsearchs( 'agent_pkg_class', \%agent_pkg_class )
+      || new FS::agent_pkg_class   \%agent_pkg_class;
+
+    my $param = 'classnum'. $agent_pkg_class{classnum};
+
+    $agent_pkg_class->commission_percent( $cgi->param($param) );
+
+    my $method = $agent_pkg_class->agentpkgclassnum ? 'replace' : 'insert';
+
+    my $error = $agent_pkg_class->$method;
+    die $error if $error; #XXX push this down into agent.pm w/better/transactional error handling
+
+  }
+
+};
+
 </%init>
index 5ee553b..31ec4ab 100755 (executable)
@@ -110,11 +110,16 @@ if ( $cgi->param('no_credit_limit') ) {
 
 $new->tagnum( [ $cgi->param('tagnum') ] );
 
+$error ||= $new->set_national_id_from_cgi( $cgi );
+
 my %usedatetime = ( 'birthdate'        => 1,
                     'spouse_birthdate' => 1,
+                    'anniversary_date' => 1,
                   );
 
-foreach my $dfield (qw( birthdate spouse_birthdate signupdate )) {
+foreach my $dfield (qw(
+  signupdate birthdate spouse_birthdate anniversary_date
+)) {
 
   if ( $cgi->param($dfield) && $cgi->param($dfield) =~ /^([ 0-9\-\/]{0,10})$/) {
 
index 6f97a79..4a71f69 100644 (file)
@@ -39,7 +39,8 @@ my $cust_pkg_discount = new FS::cust_pkg_discount {
   'amount'      => scalar($cgi->param('discountnum_amount')),
   'percent'     => scalar($cgi->param('discountnum_percent')),
   'months'      => scalar($cgi->param('discountnum_months')),
-  'setup'      => scalar($cgi->param('discountnum_setup')),
+  'setup'       => scalar($cgi->param('discountnum_setup')),
+  #'linked'       => scalar($cgi->param('discountnum_linked')),
   #'disabled'    => $self->discountnum_disabled,
 };
 my $error = $cust_pkg_discount->insert;
index f4cce65..bde4072 100755 (executable)
@@ -31,7 +31,7 @@ my $link    = $cgi->param('popup') ? 'popup' : '';
 my $payby = $cgi->param('payby');
 
 my @rights = ();
-push @rights, 'Post refund'                if $payby =~ /^(BILL|CASH)$/;
+push @rights, 'Post refund'                if $payby =~ /^(BILL|CASH|MCRD)$/;
 push @rights, 'Post check refund'          if $payby eq 'BILL';
 push @rights, 'Post cash refund '          if $payby eq 'CASH';
 push @rights, 'Refund payment'             if $payby =~ /^(CARD|CHEK)$/;
index 21150ef..6432d6b 100644 (file)
@@ -28,6 +28,11 @@ my $new = new FS::part_export ( {
   } fields('part_export')
 } );
 
+if ( $cgi->param('svc_machine') eq 'Y' ) {
+  $new->machine('_SVC_MACHINE');
+  $new->part_export_machine_textarea( $cgi->param('part_export_machine') );
+}
+
 my $error;
 if ( $exportnum ) {
   #warn $old;
index 3063198..2dadbcc 100644 (file)
@@ -2,19 +2,24 @@
 %  $cgi->param('error', $error);
 <% $cgi->redirect(popurl(3). 'misc/order_pkg.html?'. $cgi->query_string ) %>
 %} else {
-%  my $frag = "cust_pkg". $cust_pkg->pkgnum;
 %  my $show = $curuser->default_customer_view =~ /^(jumbo|packages)$/
 %               ? ''
 %               : ';show=packages';
-%  my $redir_url = popurl(3)
-%            ."view/cust_main.cgi?custnum=$custnum$show;fragment=$frag#$frag";
+%
+%  my $redir_url = popurl(3);
+%  if ( $svcpart ) { # for going straight to service provisining after ordering
+%    $redir_url .= 'edit/'.$part_svc->svcdb.'.cgi?'.
+%                    'pkgnum='.$cust_pkg->pkgnum. ";svcpart=$svcpart";
+%    $redir_url .= ";qualnum=$qualnum" if $qualnum;
+%  } elsif ( $quotationnum ) {
+%    $redir_url .= "view/quotation.html?quotationnum=$quotationnum";
+%  } else {
+%    my $custnum = $cust_main->custnum;
+%    my $frag = "cust_pkg". $cust_pkg->pkgnum;
+%    $redir_url .=
+%      "view/cust_main.cgi?custnum=$custnum$show;fragment=$frag#$frag";
+%  }
 % 
-% # for going right to a provision service after ordering a package
-% if ( $svcpart ) { 
-%   $redir_url = popurl(3)."edit/".$part_svc->svcdb.".cgi?".
-%                  "pkgnum=".$cust_pkg->pkgnum. ";svcpart=$svcpart";
-%   $redir_url .= ";qualnum=$qualnum" if $qualnum;
-% }
 <% header('Package ordered') %>
   <SCRIPT TYPE="text/javascript">
     // XXX fancy ajax rebuild table at some point, but a page reload will do for now
@@ -33,16 +38,27 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
   unless $curuser->access_right('Order customer package');
 
-#untaint custnum (probably not necessary, searching for it is escape enough)
-$cgi->param('custnum') =~ /^(\d+)$/
-  or die 'illegal custnum '. $cgi->param('custnum');
-my $custnum = $1;
-my $cust_main = qsearchs({
-  'table'     => 'cust_main',
-  'hashref'   => { 'custnum' => $custnum },
-  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
-});
-die 'unknown custnum' unless $cust_main;
+my $cust_main;
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  my $custnum = $1;
+  $cust_main = qsearchs({
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => $custnum },
+    'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+  });
+}
+
+my $prospect_main;
+if ( $cgi->param('prospectnum') =~ /^(\d+)$/ ) {
+  my $prospectnum = $1;
+  $prospect_main = qsearchs({
+    'table'     => 'prospect_main',
+    'hashref'   => { 'prospectnum' => $prospectnum },
+    'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+  });
+}
+
+die 'no custnum or prospectnum' unless $cust_main || $prospect_main;
 
 #probably not necessary, taken care of by cust_pkg::check
 $cgi->param('pkgpart') =~ /^(\d+)$/
@@ -72,47 +88,70 @@ if ( $cgi->param('svcpart') ) {
 }
 
 my $qualnum = '';
-if ( $cgi->param('qualnum') ) {
-  $cgi->param('qualnum') =~ /^(\d+)$/ or die 'illegal qualnum';
+if ( $cgi->param('qualnum') =~ /^(\d+)$/ ) {
   $qualnum = $1;
 }
+my $quotationnum = '';
+if ( $cgi->param('quotationnum') =~ /^(\d+)$/ ) {
+  $quotationnum = $1;
+}
+# verify this quotation is visible to this user
 
+my $cust_pkg = '';
+my $quotation_pkg = '';
+my $error = '';
 
-my $cust_pkg = new FS::cust_pkg {
-  'custnum'              => $custnum,
-  'pkgpart'              => $pkgpart,
-  'quantity'             => $quantity,
-  'start_date'           => ( scalar($cgi->param('start_date'))
-                                ? parse_datetime($cgi->param('start_date'))
-                                : ''
-                            ),
-  'no_auto'              => scalar($cgi->param('no_auto')),
-  'refnum'               => $refnum,
-  'locationnum'          => $locationnum,
-  'discountnum'          => $discountnum,
-  #for the create a new discount case
-  'discountnum__type'    => scalar($cgi->param('discountnum__type')),
-  'discountnum_amount'   => scalar($cgi->param('discountnum_amount')),
-  'discountnum_percent'  => scalar($cgi->param('discountnum_percent')),
-  'discountnum_months'   => scalar($cgi->param('discountnum_months')),
-  'discountnum_setup'    => scalar($cgi->param('discountnum_setup')),
-  'contract_end'         => ( scalar($cgi->param('contract_end'))
-                                ? parse_datetime($cgi->param('contract_end'))
-                                : ''
-                            ),
-   'waive_setup'         => ( $cgi->param('waive_setup') eq 'Y' ? 'Y' : '' ),
-};
-
-my %opt = ( 'cust_pkg' => $cust_pkg );
-
-if ( $locationnum == -1 ) {
-  my $cust_location = new FS::cust_location {
-    map { $_ => scalar($cgi->param($_)) }
-        qw( custnum address1 address2 city county state zip country geocode )
-  };
-  $opt{'cust_location'} = $cust_location;
-}
+my %hash = (
+    'pkgpart'              => $pkgpart,
+    'quantity'             => $quantity,
+    'start_date'           => ( scalar($cgi->param('start_date'))
+                                  ? parse_datetime($cgi->param('start_date'))
+                                  : ''
+                              ),
+    'refnum'               => $refnum,
+    'locationnum'          => $locationnum,
+    'discountnum'          => $discountnum,
+    #for the create a new discount case
+    'discountnum__type'    => scalar($cgi->param('discountnum__type')),
+    'discountnum_amount'   => scalar($cgi->param('discountnum_amount')),
+    'discountnum_percent'  => scalar($cgi->param('discountnum_percent')),
+    'discountnum_months'   => scalar($cgi->param('discountnum_months')),
+    'discountnum_setup'    => scalar($cgi->param('discountnum_setup')),
+    'contract_end'         => ( scalar($cgi->param('contract_end'))
+                                  ? parse_datetime($cgi->param('contract_end'))
+                                  : ''
+                              ),
+     'waive_setup'         => ( $cgi->param('waive_setup') eq 'Y' ? 'Y' : '' ),
+);
+$hash{'custnum'} = $cust_main->custnum if $cust_main;
+
+if ( $quotationnum ) {
+
+  $quotation_pkg = new FS::quotation_pkg \%hash;
+  $quotation_pkg->quotationnum($quotationnum);
+  $quotation_pkg->prospectnum($prospect_main->prospectnum) if $prospect_main;
 
-my $error = $cust_main->order_pkg( \%opt );
+  #XXX handle new location
+  $error = $quotation_pkg->insert;
+
+} else {
+
+  $cust_pkg = new FS::cust_pkg \%hash;
+
+  $cust_pkg->no_auto( scalar($cgi->param('no_auto')) );
+
+  my %opt = ( 'cust_pkg' => $cust_pkg );
+
+  if ( $locationnum == -1 ) {
+    my $cust_location = new FS::cust_location {
+      map { $_ => scalar($cgi->param($_)) }
+          qw( custnum address1 address2 city county state zip country geocode )
+    };
+    $opt{'cust_location'} = $cust_location;
+  }
+
+  $error = $cust_main->order_pkg( \%opt );
+
+}
 
 </%init>
index a7d5136..41aca65 100755 (executable)
@@ -56,13 +56,14 @@ my $new = new FS::svc_acct ( \%hash );
 
 my $error = '';
 
+my $part_svc = $svcnum ? 
+                $old->part_svc : 
+                qsearchs( 'part_svc', 
+                  { 'svcpart' => $cgi->param('svcpart') }
+                );
+
 # google captcha auth
 if ( $cgi->param('captcha_response') ) {
-  my $part_svc = $svcnum ? 
-                  $old->part_svc : 
-                  qsearchs( 'part_svc', 
-                    { 'svcpart' => $cgi->param('svcpart') }
-                  );
   my ($export) = $part_svc->part_export('acct_google');
   if ( $export and
       ! $export->captcha_auth($cgi->param('captcha_response')) ) { 
@@ -79,6 +80,18 @@ if (     $cgi->param('clear_password') eq '*HIDDEN*'
 }
 
 if ( ! $error ) {
+
+  my $export_info = FS::part_export::export_info();
+
+  my @svc_export_machine =
+    map FS::svc_export_machine->new({
+          'svcnum'     => $svcnum,
+          'exportnum'  => $_->exportnum,
+          'machinenum' => scalar($cgi->param('exportnum'.$_->exportnum.'machinenum')),
+        }),
+      grep { $_->machine eq '_SVC_MACHINE' }
+        $part_svc->part_export;
+
   if ( $svcnum ) {
     foreach ( grep { $old->$_ != $new->$_ }
                    qw( seconds upbytes downbytes totalbytes )
@@ -92,9 +105,9 @@ if ( ! $error ) {
       $error ||= $new->set_usage(\%hash);  #unoverlimit and trigger radius changes
       last;                                #once is enough
     }
-    $error ||= $new->replace($old);
+    $error ||= $new->replace($old, 'child_objects'=>\@svc_export_machine);
   } else {
-    $error ||= $new->insert;
+    $error ||= $new->insert('child_objects'=>\@svc_export_machine);
     $svcnum = $new->svcnum;
   }
 }
index 90eab4a..25644e5 100644 (file)
@@ -1,11 +1,10 @@
 <& elements/svc_Common.html,
-  table       => 'svc_broadband',
-  fields      => [ fields('svc_broadband'), fields('nas'), 'usergroup' ],
+  table             => 'svc_broadband',
+  fields            => [ fields('svc_broadband'), fields('nas'), 'usergroup' ],
   precheck_callback => \&precheck,
 &>
 <%init>
-# for historical reasons, process_m2m for usergroup tables is done 
-# in the svc_x::insert/replace/delete methods, not here
+
 my $curuser = $FS::CurrentUser::CurrentUser;
 
 die "access denied"
index 0c99b4c..d3ef40c 100644 (file)
@@ -7,6 +7,8 @@
     'description' => 'Description',
     'attrnum'   => 'Attribute',
     'priority'  => 'Priority',
+    'speed_down'  => 'Download speed',
+    'speed_up'    => 'Upload speed',
   },
   'viewall_dir' => 'browse',
   'menubar' => \@menubar,
       'size'      => 2,
       'colspan'   => 6, # just to not interfere with radius_attr columns
     },
+    { 'field'     => 'speed_down',
+      'type'      => 'text',
+      'size'      => 8,
+      'colspan'   => 6,
+    },
+    { 'field'     => 'speed_up',
+      'type'      => 'text',
+      'size'      => 8,
+      'colspan'   => 6,
+    },
     {
       'field'     => 'attrnum',
       'type'      => 'radius_attr',
index 620a2ea..78d0447 100644 (file)
@@ -1,50 +1,78 @@
-%
-% $cgi->param('class') =~ /^(\w)$/ or die "illegal class";
-% my $class=$1;
-%
-% my $classname = $FS::reason_type::class_name{$class};
-%
-% my (@types) = qsearch( 'reason_type', { 'class' => $class } );
-%
-% unless (scalar(@types)) {
-%   print $cgi->redirect( "reason_type.html?class=$class" );
-% }
-<% include( 'elements/edit.html',
-                 'name'   => ucfirst($classname) . ' Reason',
-                 'table'  => 'reason',
-                 'labels' => { 
-                               'reasonnum'   => ucfirst($classname) .  ' Reason',
-                               'reason_type' => ucfirst($classname) . ' Reason type',
-                               'reason'      => ucfirst($classname) . ' Reason',
-                              'disabled'    => 'Disabled',
-                               'class'       => '',
-                             },
-                'fields' => [
-                              { 'field' => 'reason_type',
-                                'type'  => 'select',
-                                 #XXX use something more sane than a hashref
-                                 #then fix tr-select.html
-                                'value' => { 'vcolumn' => 'typenum',
-                                             'ccolumn' => 'type',
-                                             'values'  => \@types,
-                                           },
-                              },
-                              'reason',
-                              { 'field' => 'class',
-                                'type'  => 'hidden',
-                                'value' => $class,
-                              },
-                              { 'field' => 'disabled',
-                                'type'  => 'checkbox',
-                                'value' => 'Y'
-                              },
-                            ],
-                 'viewall_url' => $p . "browse/reason.html?class=$class",
-           )
-%>
+<& elements/edit.html,
+  'menubar'=> [ "View all $classname Reasons" => 
+                  $p.'browse/reason.html?class='.$class,
+                "View $classname Reason Types" =>
+                  $p.'browse/reason_type.html?class='.$class,
+              ],
+  'name'   => ucfirst($classname) . ' Reason',
+  
+  'table'  => 'reason',
+  'labels' => { 
+                'reasonnum'   => $classname .  ' Reason',
+                'reason_type' => $classname . ' Reason type',
+                'reason'      => $classname . ' Reason',
+               'disabled'    => 'Disabled',
+                'class'       => '',
+                'unsuspend_pkgpart' => 'Unsuspension fee',
+                'unsuspend_hold'    => 'Delay until next bill',
+              },
+  'fields' => \@fields,
+&>
 <%init>
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
 
+$cgi->param('class') =~ /^(\w)$/ or die "illegal class";
+my $class=$1;
+
+my $classname = ucfirst($FS::reason_type::class_name{$class});
+
+my (@types) = qsearch( 'reason_type', { 'class' => $class } );
+
+unless (scalar(@types)) {
+  print $cgi->redirect( "reason_type.html?class=$class" );
+}
+
+my @fields = (
+  { 'field' => 'reason_type',
+    'type'  => 'select-table',
+    'table' => 'reason_type',
+    'name_col'  => 'type',
+    'value_col' => 'typenum',
+    'hashref'   => { 'class' => $class },
+    'disable_empty' => 1,
+#     #then fix tr-select.html
+#
+#    'value' => { 'vcolumn' => 'typenum',
+#                 'ccolumn' => 'type',
+#            'values'  => \@types,
+#          },
+#     # that wasn't so hard...did this do something else that I'm missing?
+  },
+  'reason',
+  { 'field' => 'class',
+    'type'  => 'hidden',
+    'value' => $class,
+  },
+  { 'field' => 'disabled',
+    'type'  => 'checkbox',
+    'value' => 'Y'
+  },
+);
+
+push @fields,
+  { 'field'     => 'unsuspend_pkgpart',
+    'type'      => 'select-part_pkg',
+    'hashref'   => { 'disabled' => '',
+                     'freq'     => 0 }, # one-time charges only
+  },
+  { 'field'     => 'unsuspend_hold',
+    'type'      => 'checkbox',
+    'value'     => 'Y',
+  },
+  if ( $class eq 'S' );
+  
+
+
 </%init>
index 38567ef..142c111 100755 (executable)
@@ -173,6 +173,12 @@ function randomPass() {
     <INPUT TYPE="hidden" NAME="sectornum" VALUE="<% $svc_acct->sectornum %>">
 %}
 
+<& /elements/tr-svc_export_machine.html,
+     'svc'      => $svc_acct,
+     'part_svc' => $part_svc,
+     'cgi'      => $cgi,
+&>
+
 % #uid/gid 
 % foreach my $xid (qw( uid gid )) { 
 %
index 72f596f..c6362e0 100644 (file)
             </FONT>
          </TD>
 
-%         foreach my $priority ( @custom_priorities, '' ) {
-%           my $num =
-%             FS::TicketSystem->num_customer_tickets($custnum,$priority);
-%           my $ahref = '';
-%           $ahref= '<A HREF="'.
-%                   FS::TicketSystem->href_customer_tickets($custnum,$priority).
-%                   '">'
-%             if $num;
-
+%         foreach my $priority ( @custom_priorities ) {
             <TD CLASS="grid" BGCOLOR="<% $bgcolor %>" ALIGN="right">
-             <% $ahref.$num %></A>
-           </TD>
+%           my $num = $num_tickets_by_priority{$priority}->{$custnum};
+%           if ( $num ) {
+              <A HREF="<%
+                   FS::TicketSystem->href_customer_tickets($custnum,$priority)
+                   %>"><% $num %></A>
+%             if ( $priority &&
+%                 exists($num_tickets_by_priority{''}{$custnum}) ) {
+%                   # decrement the customer's total by the number in 
+%                   # this priority bin
+%                   $num_tickets_by_priority{''}{$custnum} -= $num;
+%             }
+%           }
+          </TD>
 %         }
         </TR>
 
@@ -77,7 +80,7 @@
         <TH CLASS="grid" BGCOLOR="#cccccc"><% $line %></TH>
        <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('Lint') |h %></TH>
        <TH CLASS="grid" BGCOLOR="#cccccc"></TH>
-%       foreach my $priority ( @custom_priorities, '' ) {
+%       foreach my $priority ( @custom_priorities ) {
           <TH CLASS="grid" BGCOLOR="#cccccc">
            <% $priority || '<i>(none)</i>'%>
          </TH>
@@ -105,11 +108,83 @@ my $conf = new FS::Conf;
 
 #false laziness w/httemplate/search/cust_main.cgi... care if 
 # custom_priority_field becomes anything but a local hack...
+
 my @custom_priorities = ();
-if ( $conf->config('ticket_system-custom_priority_field')
+my $custom_priority_field = $conf->config('ticket_system-custom_priority_field');
+if ( $custom_priority_field 
      && @{[ $conf->config('ticket_system-custom_priority_field-values') ]} ) {
   @custom_priorities =
     $conf->config('ticket_system-custom_priority_field-values');
 }
-
+push @custom_priorities, '';
+
+my %num_tickets_by_priority = map { $_ => {} } @custom_priorities;
+# "optimization" (i.e. "terrible hack") to avoid constructing 
+# (@custom_priorities) x (cust_main) queries with a bazillion 
+# joins each just to count tickets
+if ( $FS::TicketSystem::system eq 'RT_Internal' 
+  and $conf->config('dashboard-toplist') )
+{
+  my $text = (driver_name =~ /^Pg/) ? 'text' : 'char';
+  # The RT API does not play nicely with aggregate queries,
+  # so we're going to go around it.
+  my $sql;
+  # optimization to keep this from taking a million years
+  my $cust_tickets =
+  "SELECT custnum, Tickets.Id, Tickets.Queue
+  FROM cust_main
+  JOIN Links ON (
+    Links.Target = 'freeside://freeside/cust_main/' || CAST(cust_main.custnum AS $text)
+    AND Links.Base LIKE '%rt://%/ticket/%'
+    AND Links.Type = 'MemberOf'
+  ) JOIN Tickets ON (Links.LocalBase = Tickets.Id)
+  UNION
+  SELECT custnum, Tickets.Id, Tickets.Queue
+  FROM cust_pkg JOIN cust_svc USING (pkgnum)
+  JOIN Links ON (
+    Links.Target = 'freeside://freeside/cust_svc/' || CAST(cust_svc.svcnum AS $text)
+    AND Links.Base LIKE '%rt://%/ticket/%'
+    AND Links.Type = 'MemberOf'
+  ) JOIN Tickets ON (Links.LocalBase = Tickets.Id)
+  ";
+
+  if ( $custom_priority_field )  {
+    $sql = 
+    "SELECT cust_tickets.custnum AS custnum,
+            ObjectCustomFieldValues.Content as priority,
+            COUNT(DISTINCT cust_tickets.Id) AS num_tickets
+     FROM ($cust_tickets) AS cust_tickets
+        LEFT JOIN ObjectCustomFields ON (
+          ObjectCustomFields.ObjectId = '0' OR 
+          ObjectCustomFields.ObjectId = cust_tickets.Queue
+        )
+        LEFT JOIN CustomFields ON (
+          ObjectCustomFields.CustomField = CustomFields.Id AND
+          CustomFields.Name = '$custom_priority_field'
+        )
+        LEFT JOIN ObjectCustomFieldValues ON (
+          ObjectCustomFieldValues.CustomField = CustomFields.Id AND
+          ObjectCustomFieldValues.ObjectType = 'RT::Ticket' AND
+          ObjectCustomFieldValues.Disabled = '0' AND
+          ObjectCustomFieldValues.ObjectId = cust_tickets.Id
+        )
+      GROUP BY cust_tickets.custnum, ObjectCustomFieldValues.Content";
+      #warn $sql."\n";
+  } else { # no custom_priority_field
+    $sql =
+    "SELECT cust_tickets.custnum,
+            '' as priority,
+            COUNT(DISTINCT cust_tickets.Id) AS num_tickets
+     FROM ($cust_tickets) AS cust_tickets
+      GROUP BY cust_tickets.custnum";
+  }
+  my $sth = dbh->prepare($sql) or die dbh->errstr;
+  $sth->execute or die $sth->errstr;
+  while ( my $row = $sth->fetchrow_hashref ) {
+  #warn to_json($row)."\n";
+    $num_tickets_by_priority{ $row->{priority} }->{ $row->{custnum} } =
+      $row->{num_tickets};
+  }
+}
+#warn Dumper \%num_tickets_by_priority;
 </%init>
index 7672318..5c7c888 100644 (file)
@@ -11,8 +11,10 @@ Example:
              'no_asterisks'   => 0, #set true to disable the red asterisks next
                                     #to required fields
              'address1_label' => 'Address', #label for address
+             'enable_coords'  => 1, #show latitude/longitude fields
              'enable_district' => 1, #show tax district field
              'enable_censustract' => 1, #show censustract field
+             
          )
 
 </%doc>
@@ -175,6 +177,7 @@ Example:
   <TD COLSPAN=6><% include('/elements/select-country.html', %select_hash ) %></TD>
 </TR>
 
+% if ( $opt{enable_coords} ) {
 <TR>
   <TD ALIGN="right"><% mt('Latitude') |h %></TH>
   <TD COLSPAN=7>
@@ -195,6 +198,11 @@ Example:
     >
   </TD>
 </TR>
+% } else {
+%   foreach (qw(latitude longitude)) {
+<INPUT TYPE="hidden" NAME="<% $_ %>" VALUE="<% $object->get($_) |h%>">
+%   }
+% }
 <INPUT TYPE="hidden" NAME="<%$pre%>coord_auto" VALUE="<% $object->coord_auto %>">
 
 <INPUT TYPE="hidden" NAME="<%$pre%>geocode" VALUE="<% $object->geocode %>">
index 019afe9..b2141e9 100644 (file)
@@ -94,6 +94,11 @@ tie my %report_prospects, 'Tie::IxHash',
   'Advanced prospect reports' => [ $fsurl. 'search/report_prospect_main.html', '' ],
 ;
 
+tie my %report_quotations, 'Tie::IxHash',
+  'List quotations' => [ $fsurl. 'search/quotation.html', '' ],
+  'Advanced quotation reports' => [ $fsurl. 'search/report_quotation.html', '' ],
+;
+
 tie my %report_customers_lists, 'Tie::IxHash',
   'by customer number' => [ $fsurl. 'search/cust_main.cgi?browse=custnum', '' ],
   'by last name' => [ $fsurl. 'search/cust_main.cgi?browse=last', '' ],
@@ -256,6 +261,8 @@ tie my %report_inventory, 'Tie::IxHash',
 tie my %report_rating, 'Tie::IxHash';
 $report_rating{'RADIUS sessions'} = [ $fsurl.'search/sqlradius.html', '' ]
   if $curuser->access_right("Usage: RADIUS sessions");
+$report_rating{'RADIUS data usage'} = [ $fsurl.'search/report_sqlradius_usage.html', '' ]
+  if $curuser->access_right("Usage: RADIUS sessions");
 $report_rating{'Call Detail Records (CDRs)'} = [ $fsurl.'search/report_cdr.html', '' ]
   if $curuser->access_right("Usage: Call Detail Records (CDRs)");
 $report_rating{'Unrateable CDRs'} = [ $fsurl.'search/cdr.html?freesidestatus=failed;cdrbatchnum=_ALL_' ]
@@ -342,6 +349,8 @@ if($curuser->access_right('Financial reports')) {
 tie my %report_menu, 'Tie::IxHash';
 $report_menu{'Prospects'}   = [ \%report_prospects, 'Prospect reports' ]
   if $curuser->access_right('List prospects');
+$report_menu{'Quotations'}  = [ \%report_quotations, 'Quotation reports' ]
+  if $curuser->access_right('List quotations');
 $report_menu{'Customers'}   = [ \%report_customers, 'Customer reports'  ]
   if $curuser->access_right('List customers');
 $report_menu{'Invoices'}    =  [ \%report_invoices,  'Invoice reports'   ]
index 7a45bb1..85758d5 100644 (file)
@@ -3,9 +3,6 @@
 <OPTION VALUE="<% shift @fields %>"><% shift @fields %></OPTION>
 % }
 </SELECT>
-<%once>
-RT::Init();
-</%once>
 <%init>
 my %opt = @_;
 my $lookuptype = $opt{lookuptype};
index 127028e..c0cd7a5 100644 (file)
@@ -181,24 +181,29 @@ if ( $opt{'records'} ) {
   });
 }
 
-unless (    $value < 1 # !$value #ignore negatives too
-         or ref($value)
+if ( ref( $value ) eq 'ARRAY' ) {
+  $value = { map { $_ => 1 } @$value };
+}
+
+unless (    !ref($value) && $value < 1 # !$value #ignore negatives too
          or ! exists( $opt{hashref}->{disabled} ) #??
-         or grep { $value == $_->$key() } @records
+         #or grep { $value == $_->$key() } @records
        ) {
   delete $opt{hashref}->{disabled};
-  $opt{hashref}->{$key} = $value;
-  my $record = qsearchs( {
-    'table'     => $opt{table},
-    'addl_from' => $opt{'addl_from'},
-    'hashref'   => $hashref,
-    'extra_sql' => $extra_sql,
-  });
-  push @records, $record if $record;
-}
 
-if ( ref( $value ) eq 'ARRAY' ) {
-  $value = { map { $_ => 1 } @$value };
+  foreach my $v ( ref($value) ? keys %$value : ($value) ) {
+    next if grep { $v == $_->$key() } @records;
+
+    $opt{hashref}->{$key} = $v;
+    my $record = qsearchs( {
+      'table'     => $opt{table},
+      'addl_from' => $opt{'addl_from'},
+      'hashref'   => $hashref,
+      'extra_sql' => $extra_sql,
+    });
+    push @records, $record if $record;
+
+  }
 }
 
 my @pre_options  = $opt{pre_options}  ? @{ $opt{pre_options} } : ();
index a1a9e34..1248852 100644 (file)
@@ -90,7 +90,9 @@ if ( $amount > 0 ) {
   $amount += $fee
     if $fee && $fee_display eq 'subtract';
 
-  &{ $opt{post_fee_callback} }( \$amount ) if $opt{post_fee_callback};
+  #&{ $opt{post_fee_callback} }( \$amount ) if $opt{post_fee_callback};
+  $amount += $amount * $opt{'surcharge_percentage'}/100
+    if $opt{'surcharge_percentage'} > 0;
 
   $amount = sprintf("%.2f", $amount);
 }
index d9e3e9e..b804f45 100644 (file)
@@ -216,6 +216,7 @@ Example:
              'no_asterisks' => 1,
              'no_bold'      => $opt{'no_bold'},
              'alt_format'   => $opt{'alt_format'},
+             'enable_coords'=> 1,
           )
 %>
 <SCRIPT TYPE="text/javascript">
index 30a60ec..ee86251 100644 (file)
@@ -6,7 +6,7 @@
 % } else { 
 
   <TR>
-    <TD ALIGN="right" WIDTH="176"><% $opt{'label'} || '<B>'.emt('Discount').'</B>' %></TD>
+    <TD ALIGN="right" WIDTH="275"><% $opt{'label'} || '<B>'.emt('Discount').'</B>' %></TD>
     <TD <% $colspan %>>
       <% include( '/elements/select-discount.html',
                     'curr_value' => $discountnum,
             )
   %>
 
+%#  <% include( '/elements/tr-checkbox.html',
+%#                'label'     => '<B>Apply discount to add-on packages</B>',
+%#                'field'     => $name.'_linked',
+%#                'id'        => $name.'_linked',
+%#                'curr_value' => scalar($cgi->param($name.'_linked')),
+%#                'value'     => 'Y',
+%#                'colspan'    => $opt{'colspan'},
+%#            )
+%#  %>
+
   <SCRIPT TYPE="text/javascript">
 
 %   my $ge = 'document.getElementById';
         <% $ge %>('<% $name %>_percent_label0').style.visibility = 'hidden';
         <% $ge %>('<% $name %>_percent_input0').style.display = 'none';
         <% $ge %>('<% $name %>_percent_input0').style.visibility = 'hidden';
+//        <% $ge %>('<% $name %>_linked_label0').style.display = 'none';
+//        <% $ge %>('<% $name %>_linked_label0').style.visibility = 'hidden';
+//        <% $ge %>('<% $name %>_linked').style.display = 'none';
+//        <% $ge %>('<% $name %>_linked').style.visibility = 'hidden';
       } else if ( <% $name %>__type == 'Amount' ) {
         <% $ge %>('<% $name %>_amount_label0').style.display = '';
         <% $ge %>('<% $name %>_amount_label0').style.visibility = '';
         <% $ge %>('<% $name %>_percent_label0').style.visibility = 'hidden';
         <% $ge %>('<% $name %>_percent_input0').style.display = 'none';
         <% $ge %>('<% $name %>_percent_input0').style.visibility = 'hidden';
+        <% $ge %>('<% $name %>_percent_input0').style.visibility = 'hidden';
+//        <% $ge %>('<% $name %>_linked_label0').style.display = 'none';
+//        <% $ge %>('<% $name %>_linked_label0').style.visibility = 'hidden';
+//        <% $ge %>('<% $name %>_linked').style.display = 'none';
+//        <% $ge %>('<% $name %>_linked').style.visibility = 'hidden';
       } else if ( <% $name %>__type == 'Percentage' ) {
         <% $ge %>('<% $name %>_amount_label0').style.display = 'none';
         <% $ge %>('<% $name %>_amount_label0').style.visibility = 'hidden';
         <% $ge %>('<% $name %>_percent_label0').style.visibility = '';
         <% $ge %>('<% $name %>_percent_input0').style.display = '';
         <% $ge %>('<% $name %>_percent_input0').style.visibility = '';
+        <% $ge %>('<% $name %>_percent_input0').style.visibility = '';
+//        <% $ge %>('<% $name %>_linked_label0').style.display = '';
+//        <% $ge %>('<% $name %>_linked_label0').style.visibility = '';
+//        <% $ge %>('<% $name %>_linked').style.display = '';
+//        <% $ge %>('<% $name %>_linked').style.visibility = '';
      }
 
     }
index 765aa84..5041f7f 100644 (file)
      <INPUT TYPE="hidden" NAME="<% $opt{'element_name'} || $opt{'field'} || 'refnum' %>" VALUE="<% $opt{'part_referrals'}->[0]->refnum %>">
 
 % } else { 
-
-     <TR>
-%      if ( $opt{'label'} ) {
-         <TD ALIGN="right"><% $opt{'label'} %></TD>
-%      } else {
-         <TH ALIGN="right"><%$r%><% mt('Advertising source') |h %></TH>
-%      }
+     <& /elements/tr-td-label.html, label => 'Advertising source', %opt &>
        <TD COLSPAN="<% $colspan %>">
          <& /elements/select-part_referral.html,
                        'curr_value' => $refnum,
index 5a79d68..c1df10b 100755 (executable)
@@ -32,8 +32,15 @@ Example:
 <SCRIPT TYPE="text/javascript">
   function sh_add<% $func_suffix %>()
   {
+    var hints = <% encode_json(\@hints) %>;
+    var select_reason = document.getElementById('<% $id %>');
 
-    if (document.getElementById('<% $id %>').selectedIndex == 0){
+% if ( $class eq 'S' ) {    
+    document.getElementById('<% $id %>_hint').innerHTML =
+      hints[select_reason.selectedIndex];
+% }
+
+    if (select_reason.selectedIndex == 0){
       <% $controlledbutton ? $controlledbutton.'.disabled = true;' : ';' %>
     }else{
       <% $controlledbutton ? $controlledbutton.'.disabled = false;' : ';' %>
@@ -41,8 +48,8 @@ Example:
 
 %if ($curuser->access_right($add_access_right)){
 
-    if (document.getElementById('<% $id %>').selectedIndex == 
-         (document.getElementById('<% $id %>').length - 1)) {
+    if (select_reason.selectedIndex == 
+         (select_reason.length - 1)) {
       document.getElementById('new<% $id %>').disabled = false;
       document.getElementById('new<% $id %>').style.display = 'inline';
       document.getElementById('new<% $id %>Label').style.display = 'inline';
@@ -113,6 +120,13 @@ Example:
 </TR>
 %   }
 
+% if ( $class eq 'S' ) {
+<TR>
+  <TD COLSPAN=2 ALIGN="center" id="<% $id %>_hint">
+  </TD>
+</TR>
+% }
+
 <TR>
   <TD ALIGN="right">
     <P id="new<% $id %>Label" style="display:<% $display %>"><% mt('New Reason') |h %></P>
@@ -184,6 +198,43 @@ my @reasons = qsearch({
   order_by  => 'ORDER BY reason_type.type ASC, reason.reason ASC',
 });
 
+my @hints;
+if ( $class eq 'S' ) {
+  my $conf = FS::Conf->new;
+  @hints = ( '' );
+  foreach my $reason (@reasons) {
+    if ( $reason->unsuspend_pkgpart ) {
+      my $part_pkg = FS::part_pkg->by_key($reason->unsuspend_pkgpart);
+      if ( $part_pkg ) {
+        if ( $part_pkg->option('setup_fee',1) > 0 and 
+             $part_pkg->option('recur_fee',1) == 0 ) {
+          # the usual case
+          push @hints,
+            mt('A [_1] unsuspension fee will apply.', 
+               ($conf->config('money_char') || '$') .
+               sprintf('%.2f', $part_pkg->option('setup_fee'))
+               );
+        } else {
+          # oddball cases--not really supported
+          push @hints,
+            mt('An unsuspension package will apply: [_1]',
+              $part_pkg->price_info
+              );
+        }
+      } else { #no $part_pkg
+        push @hints,
+          '<FONT COLOR="#ff0000">Unsuspend pkg #'.$reason->unsuspend_pkgpart.
+          ' not found.</FONT>';
+      }
+    } else { #no unsuspend_pkgpart
+      push @hints, '';
+    }
+  }
+  push @hints, ''; # for the "new reason" case
+  @hints = map {'<FONT SIZE="-1">'.$_.'</FONT>'} @hints;
+}
+
+
 my $curuser = $FS::CurrentUser::CurrentUser;
 
 </%init>
diff --git a/httemplate/elements/tr-select-voip_class.html b/httemplate/elements/tr-select-voip_class.html
new file mode 100644 (file)
index 0000000..dcc1487
--- /dev/null
@@ -0,0 +1,24 @@
+<& tr-td-label.html, label => 'Category', @_ &>
+<TD>
+<SELECT NAME="<% $opt{'field'} %>">
+% while(@options) {
+%   my $value = shift @options;
+%   my $selected = ($value eq $opt{'curr_value'}) ? 'SELECTED' : '';
+  <OPTION VALUE="<% $value %>" <% $selected %>><% shift @options %></OPTION>
+% }
+</SELECT>
+</TD></TR>
+<%init>
+my %opt = (
+  field => 'fcc_voip_class',
+  label => 'Category',
+  @_
+);
+my @options = (
+  '' => '',
+   1 => 'VoIP without Broadband',
+   2 => 'VoIP with Broadband',
+   3 => 'Wholesale VoIP'
+);
+
+</%init>
diff --git a/httemplate/elements/tr-svc_export_machine.html b/httemplate/elements/tr-svc_export_machine.html
new file mode 100644 (file)
index 0000000..92b6ac1
--- /dev/null
@@ -0,0 +1,37 @@
+% foreach my $part_export (@part_export) {
+%   my $label = ( $part_export->exportname
+%                   ? $part_export->exportname
+%                   : $part_export->label
+%               ).
+%               ' hostname';
+%
+%   my $element = 'exportnum'. $part_export->exportnum. 'machinenum';
+%   my $machinenum = $opt{cgi}->param($element);
+%   if ( ! $machinenum && $opt{svc}->svcnum ) {
+%     my $svc_export_machine = qsearchs('svc_export_machine', {
+%       'svcnum'    => $opt{svc}->svcnum,
+%       'exportnum' => $part_export->exportnum,
+%     });
+%     $machinenum = $svc_export_machine->machinenum if $svc_export_machine;
+%   }
+
+    <& /elements/tr-select-table.html,
+         'label'        => $label,
+         'element_name' => 'exportnum'. $part_export->exportnum. 'machinenum',
+         'table'        => 'part_export_machine',
+         'name_col'     => 'machine',
+         'hashref'      => { 'exportnum' => $part_export->exportnum,
+                             'disabled'  => '',
+                           },
+         'curr_value'   => $machinenum,
+         'empty_label'  => 'Select export hostname',
+    &>
+% }
+<%init>
+
+my %opt = @_;
+
+my @part_export = grep { $_->machine eq '_SVC_MACHINE' }
+                    $opt{part_svc}->part_export;
+
+</%init>
index e7a3bd2..c334ae9 100644 (file)
@@ -8,6 +8,7 @@
                 'graph_labels' => \@labels,
                 'colors'       => \@colors,
                 'links'        => \@links,
+                'no_graph'     => \@no_graph,
                 'remove_empty' => 1,
                 'bottom_total' => 1,
                 'bottom_link'  => $bottom_link,
@@ -118,6 +119,7 @@ my @params = ();
 my @labels = ();
 my @colors = ();
 my @links  = ();
+my @no_graph;
 
 my @components = ( 'SRU' );
 # split/omit components as appropriate
@@ -134,6 +136,11 @@ elsif ( $use_usage == 2 ) {
   $components[-1] =~ s/U//;
 }
 
+# Categorization of line items goes
+# Agent -> Referral -> Package class -> Component (setup/recur/usage)
+# If per-agent totals are enabled, they go under the Agent level.
+# There aren't any other kinds of subtotals.
+
 foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' => '' } ) ) {
 
   my $col_scheme = Color::Scheme->new
@@ -146,7 +153,11 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' =>
   ### fixup the color handling for package classes...
   ### and usage
 
-  foreach my $part_referral ( $all_part_referral || $sel_part_referral || qsearch('part_referral', { 'disabled' => '' } ) ) {
+  foreach my $part_referral (
+    $all_part_referral ||
+    $sel_part_referral ||
+    qsearch('part_referral', { 'disabled' => '' } ) 
+  ) {
 
     foreach my $pkg_class ( @pkg_class ) {
       foreach my $component ( @components ) {
@@ -186,9 +197,46 @@ foreach my $agent ( $all_agent || $sel_agent || qsearch('agent', { 'disabled' =>
         @onetime_colors = ($col_scheme->colors)[2,6,10,3,7,11]
           unless @onetime_colors;
         push @colors, shift @recur_colors;
-
-      }
+        push @no_graph, 0;
+
+      } #foreach $component
+    } #foreach $pkg_class
+  } #foreach $part_referral
+
+  if ( $cgi->param('agent_totals') and !$all_agent ) {
+    my $row_agentnum = $agent->agentnum;
+    # Include all components that are anywhere on this report
+    my $component = join('', @components);
+
+    my @row_params = (  'agentnum'              => $row_agentnum,
+                        'use_override'          => $use_override,
+                        'average_per_cust_pkg'  => $average_per_cust_pkg,
+                        'distribute'            => $distribute,
+                        'charges'               => $component,
+                     );
+    my $row_link = "$link;".
+                   "agentnum=$row_agentnum;".
+                   "distribute=$distribute;".
+                   "charges=$component";
+    
+    # Also apply any refnum/classnum filters
+    if ( !$all_class and scalar(@pkg_class) == 1 ) {
+      # then a specific class has been chosen, but it may be the empty class
+      my $row_classnum = ref($pkg_class[0]) ? $pkg_class[0]->classnum : 0;
+      push @row_params, 'classnum' => $row_classnum;
+      $row_link .= ";classnum=$row_classnum";
     }
+    if ( $sel_part_referral ) {
+      push @row_params, 'refnum' => $sel_part_referral->refnum;
+      $row_link .= ";refnum=".$sel_part_referral->refnum;
+    }
+
+    push @items, 'cust_bill_pkg';
+    push @labels, mt('[_1] - Subtotal', $agent->agent);
+    push @params, \@row_params;
+    push @links, $row_link;
+    push @colors, '000000'; # better idea?
+    push @no_graph, 1;
   }
 
   $hue += $hue_increment;
index 839a387..c736de6 100644 (file)
@@ -20,6 +20,7 @@ Example:
     'link_fromparam'  => 'param_from', #defaults to 'begin'
     'link_toparam'    => 'param_to',   #defaults to 'end'
     'daily'           => 1, # omit for monthly granularity
+    'no_graph'        => \@no_graph, # items to leave off the graph (subtotals)
 
     #optional, pulled from CGI params if not specified
     'start_month'     => $smonth,
@@ -49,18 +50,19 @@ Example:
             'items'         => $data->{'items'},
             'data'          => $data->{'data'},
             'row_labels'    => $data->{'item_labels'},
-            'graph_labels'  => $opt{'graph_labels'} || $data->{'item_labels'},
+            'graph_labels'  => \@graph_labels,
             'col_labels'    => $col_labels,
             'axis_labels'   => $data->{label},
-            'colors'        => $data->{colors},
+            'colors'        => \@colors,
             'links'         => \@links,
+            'no_graph'      => \@no_graph,
             'bottom_link'   => \@bottom_link,
             'transpose'     => $opt{'daily'},
-            map { $_, $opt{$_} } (qw(title 
-                                    nototal 
-                                    graph_type 
-                                    bottom_total 
-                                    sprintf 
+            map { $_, $opt{$_} } (qw(title
+                                    nototal
+                                    graph_type
+                                    bottom_total
+                                    sprintf
                                     disable_money
                                     chart_options)),
           ) %>
@@ -103,7 +105,7 @@ if ( $opt{'daily'} ) { # daily granularity
 my %reportopts = (
       'items'        => \@items,
       'params'       => $opt{'params'},
-      'item_labels'  => ( $cgi->param('_type') =~ /^(png)$/
+      'item_labels'  => ( $cgi->param('_type') =~ /^(png)$/ 
                             ? $opt{'graph_labels'}
                             : $opt{'labels'}
                         ),
@@ -140,12 +142,20 @@ my $col_labels = [ map { my $m = $_; $m =~ s/^(\d+)\//$mon[$1-1] / ; $m }
                              @{$data->{label}} ];
 $col_labels = $data->{label} if $opt{'daily'};
 
+my @colors;
+my @graph_labels;
+my @no_graph;
 if ( $opt{'remove_empty'} ) {
-  # need to filter out series labels for collapsed rows
-  $opt{'graph_labels'} = [ 
-    map { $opt{'graph_labels'}[$_] } 
-    @{ $data->{indices} }
-  ];
+  # then filter out per-item things for collapsed rows
+  foreach my $i (@{ $data->{'indices'} }) {
+    push @colors,       $opt{'colors'}[$i];
+    push @graph_labels, $opt{'graph_labels'}[$i];
+    push @no_graph,     $opt{'no_graph'}[$i];
+  }
+} else {
+  @colors       = @{ $opt{'colors'} };
+  @graph_labels = @{ $opt{'graph_labels'} };
+  @no_graph     = @{ $opt{'no_graph'} || [] };
 }
 
 my @links;
index f774616..98b4778 100644 (file)
@@ -14,6 +14,7 @@ Example:
     'graph_labels'    => \@graph_labels,  #defaults to row_labels
 
     'links'           => \@links,         #optional
+    'no_graph'        => \@no_graph,      #optional
 
     #these run parallel to the elements of each @item
     'col_labels'      => \@col_labels,    #required
@@ -128,7 +129,19 @@ any delimiter and linked from the elements in @data.
 %   
 <% $output %>
 % } elsif ( $cgi->param('_type') eq 'png' ) {
-%
+%   # delete any items that shouldn't be on the graph
+%   if ( my $no_graph = $opt{'no_graph'} ) {
+%     my $i = 0;
+%     while (@$no_graph) {
+%       if ( shift @$no_graph ) {
+%         splice @data, $i, 1;
+%         splice @{$opt{'graph_labels'}}, $i, 1;
+%         splice @{$opt{'colors'}}, $i, 1;
+%         $i--; # because everything is shifted down
+%       }
+%       $i++;
+%     }
+%   }
 %   my $graph_type = 'LinesPoints';
 %   if ( $opt{'graph_type'} =~ /^(LinesPoints|Mountain|Bars)$/ ) {
 %     $graph_type = $1;
index 4cedcef..31792e8 100644 (file)
   <TD>Show projected data for future months</TD>
 </TR>
 
-<% include('/elements/tr-select-agent.html',
-             'label'         => 'Agent ',
-             'disable_empty' => 0,
-             'pre_options'   => [ 'all' => 'all (aggregate)' ],
-             'empty_label'   => 'all (breakdown)',
-          )
-%>
-
-<% include('/elements/tr-select-part_referral.html',
-             'label'         => 'Advertising source ',
-             'disable_empty' => 0,
-             'pre_options'   => [ 'all' => 'all (aggregate)' ],
-             'empty_label'   => 'all (breakdown)',
-          )
-%>
-
-<% include('/elements/tr-select-pkg_class.html',
-              'pre_options' => [ 'all'  => 'all (aggregate)',
-                                 '0' => 'all (breakdown)' ],
-              'empty_label' => '(empty class)',
-           )
-%>
+<SCRIPT TYPE="text/javascript">
+function enable_agent_totals(obj) {
+%# enable it iff we are breaking down by agent AND something else
+  obj.form.agent_totals.disabled = !(
+    obj.form.agentnum.value == '' && (
+      obj.form.refnum.value == ''   ||
+      obj.form.classnum.value == 0  ||
+      obj.form.use_setup.value == 1 ||
+      obj.form.use_usage.value == 1
+    )
+  );
+}
+</SCRIPT>
+
+<& /elements/tr-select-agent.html,
+  'field'         => 'agentnum',
+  'label'         => 'Agent ',
+  'disable_empty' => 0,
+  'pre_options'   => [ 'all' => 'all (aggregate)' ],
+  'empty_label'   => 'all (breakdown)',
+  'onchange'      => 'enable_agent_totals',
+&>
+
+<& /elements/tr-select-part_referral.html,
+  'field'         => 'refnum',
+  'label'         => 'Advertising source ',
+  'disable_empty' => 0,
+  'pre_options'   => [ 'all' => 'all (aggregate)' ],
+  'empty_label'   => 'all (breakdown)',
+  'onchange'      => 'enable_agent_totals'
+&>
+
+<& /elements/tr-select-pkg_class.html,
+  'field'       => 'classnum',
+  'pre_options' => [ 'all'  => 'all (aggregate)',
+                        '0' => 'all (breakdown)' ],
+  'empty_label' => '(empty class)',
+  'onchange'    => 'enable_agent_totals',
+&>
 
 <!--
 <TR>
     'field'   => 'use_'.lc($_),
     'options' => [ 0, 1, 2 ],
     'labels'  => { 0 => 'Combine', 1 => 'Separate', 2 => 'Do not show' },
+    'onchange'=> 'enable_agent_totals',
 &>
 % }
 
 <TR>
+  <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="agent_totals" VALUE="1" DISABLED="1"></TD>
+  <TD>Show per-agent subtotals</TD>
+</TR>
+
+<TR>
   <TD ALIGN="right"><INPUT TYPE="checkbox" NAME="use_override" VALUE="1"></TD>
   <TD>Separate sub-packages from parents</TD>
 </TR>
index ae15096..71926aa 100644 (file)
@@ -1,9 +1,18 @@
+<%init>my $debug = $cgi->param('debug');</%init>
+% warn time.": header.html\n" if $debug;
+%
 <& /elements/header.html, mt('Billing Main') &>
 
+% warn time.": dashboard-install_welcome.html\n" if $debug;
+%
 <& /elements/dashboard-install_welcome.html &>
 
+% warn time.": dashboard-toplist.html\n" if $debug;
+%
 <& /elements/dashboard-toplist.html &>
 
+% warn time.": fetching recently changed customers\n" if $debug;
+%
 %  my $sth = dbh->prepare(
 %    #"SELECT DISTINCT custnum FROM h_cust_main JOIN cust_main USING ( custnum )
 %    "SELECT custnum FROM h_cust_main JOIN cust_main USING ( custnum )
@@ -20,6 +29,7 @@
 %  @custnums = splice(@custnums, 0, 10);
 %
 %  if ( @custnums ) {
+%    warn time.": displaying recently changed customers\n" if $debug;
 
   <& /elements/table-grid.html &>
 
index 74f9b4c..d56feac 100644 (file)
@@ -36,6 +36,7 @@ Import a file containing customer records.
         <OPTION VALUE="svc_external">External service
         <OPTION VALUE="svc_external_svc_phone">External service and phone service
         <OPTION VALUE="birthdates-acct_phone_hardware">Birthdates and account, phone and hardware services
+        <OPTION VALUE="national_id-acct_phone">National ID, plus account and phone services
       </SELECT>
     </TD>
   </TR>
@@ -110,6 +111,9 @@ Uploaded files can be CSV (comma-separated value) files or Excel spreadsheets.
 <b>Birthdates and account, phone and hardware services</b> format has the following field order: <i>agent_custid, refnum<%$req%>, last<%$req%>, first<%$req%>, company, address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country, daytime, night, ship_last, ship_first, ship_company, ship_address1, ship_address2, ship_city, ship_state, ship_zip, ship_country, birthdate, spouse_birthdate, payinfo, paycvv, paydate, invoicing_list, pkgpart, next_bill_date, username, _password, countrycode, phonenum, sip_password, pin, typenum, ip_addr, hw_addr, serial</i>
 <BR><BR>
 
+<b>National ID, plus account and phone services</b> format has the following field order: <i>agent_custid, refnum<%$req%>, last<%$req%>, first<%$req%>, company, address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country, daytime, night, ship_last, ship_first, ship_company, ship_address1, ship_address2, ship_city, ship_state, ship_zip, ship_country, national_id, payinfo, paycvv, paydate, invoicing_list, pkgpart, next_bill_date, username, _password, slipip, countrycode, phonenum, sip_password, pin</i>
+<BR><BR>
+
 <%$req%> Required fields
 <BR><BR>
 
@@ -135,6 +139,8 @@ advertising source table.
 
   <li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified. (Extended and Extended plus company formats)
 
+  <li><i>slipip</i>: IP address
+
   <li><i>id</i>: External service id, integer
 
   <li><i>title</i>: External service identifier, text
index 7aa024a..c5f4509 100644 (file)
@@ -1,4 +1,6 @@
-<& /elements/header-popup.html, mt('Order new package') &>
+<& /elements/header-popup.html, $quotationnum ? mt('Add package to quotation')
+                                              : mt('Order new package')
+&>
 
 <LINK REL="stylesheet" TYPE="text/css" HREF="../elements/calendar-win2k-2.css" TITLE="win2k-2">
 <SCRIPT TYPE="text/javascript" SRC="../elements/calendar_stripped.js"></SCRIPT>
 
 <FORM NAME="OrderPkgForm" ACTION="<% $p %>edit/process/quick-cust_pkg.cgi" METHOD="POST">
 
-<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $cust_main->custnum %>">
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $cust_main ? $cust_main->custnum : '' %>">
+<INPUT TYPE="hidden" NAME="prospectnum" VALUE="<% $prospect_main ? $prospect_main->prospectnum : '' %>">
 <INPUT TYPE="hidden" NAME="qualnum" VALUE="<% scalar($cgi->param('qualnum')) |h %>">
+<INPUT TYPE="hidden" NAME="quotationnum" VALUE="<% $quotationnum %>">
 % if ( $svcpart ) {
     <INPUT TYPE="hidden" NAME="svcpart" VALUE="<% $svcpart %>">
 % }
     </TR>
 % } else {
     <& /elements/tr-select-cust-part_pkg.html,
-                 'curr_value' => $pkgpart,
-                 'classnum'   => -1,
-                 'cust_main'  => $cust_main,
+                 'curr_value'    => $pkgpart,
+                 'classnum'      => -1,
+                 'cust_main'     => $cust_main,
+                 'prospect_main' => $prospect_main,
     &>
 % }
 
@@ -39,6 +44,8 @@
         <INPUT TYPE="text" NAME="quantity" SIZE=4 VALUE="<% $quantity %>">
       </TD>
     </TR>
+% } else {
+    <INPUT TYPE="hidden" NAME="quantity" VALUE="1">
 % }
 
 <TR>
@@ -54,7 +61,7 @@
   </TD>
 </TR>
 
-% if ( $cust_main->payby =~ /^(CARD|CHEK)$/ ) {
+% if ( $cust_main && $cust_main->payby =~ /^(CARD|CHEK)$/ ) {
 %   my $what = lc(FS::payby->shortname($cust_main->payby));
     <TR>
       <TH ALIGN="right"><% mt("Disable automatic $what charge") |h %> </TH>
 % } else {
 
     <& /elements/tr-select-cust_location.html,
-                 'cgi'       => $cgi,
-                 'cust_main' => $cust_main,
+                 'cgi'           => $cgi,
+                 'cust_main'     => $cust_main,
+                 'prospect_main' => $prospect_main,
     &>
 
 % }
@@ -152,20 +160,42 @@ die "access denied"
 my $conf = new FS::Conf;
 my $date_format = $conf->config('date_format') || '%m/%d/%Y';
 
-$cgi->param('custnum') =~ /^(\d+)$/ or die "no custnum";
-my $custnum = $1;
-my $cust_main = qsearchs({
-  'table'     => 'cust_main',
-  'hashref'   => { 'custnum' => $custnum },
-  'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
-});
+my $cust_main = '';
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  my $custnum = $1;
+  $cust_main = qsearchs({
+    'table'     => 'cust_main',
+    'hashref'   => { 'custnum' => $custnum },
+    'extra_sql' => ' AND '. $curuser->agentnums_sql,
+  });
+}
+
+my $prospect_main = '';
+if ( $cgi->param('prospectnum') =~ /^(\d+)$/ ) {
+  my $prospectnum = $1;
+  $prospect_main = qsearchs({
+    'table'     => 'prospect_main',
+    'hashref'   => { 'prospectnum' => $prospectnum },
+    'extra_sql' => ' AND '. $curuser->agentnums_sql,
+  });
+}
+
+my $quotationnum = '';
+if ( $cgi->param('quotationnum') =~ /^(\d+)$/ ) {
+  $quotationnum = $1;
+}
+
+die 'no custnum or prospectnum' unless $cust_main || $prospect_main;
 
 my $part_pkg = '';
 if ( $cgi->param('lock_pkgpart') ) {
   $part_pkg = qsearchs({
     'table'     => 'part_pkg',
     'hashref'   => { 'pkgpart' => scalar($cgi->param('lock_pkgpart')) },
-    'extra_sql' => ' AND '. FS::part_pkg->agent_pkgs_sql( $cust_main->agent ),
+    'extra_sql' => ' AND '. FS::part_pkg->agent_pkgs_sql(
+                              $cust_main ? $cust_main->agent
+                                         : $prospect_main->agent
+                            ),
   })
     or die "unknown pkgpart ". $cgi->param('lock_pkgpart');
 }
@@ -179,7 +209,7 @@ if ( $cgi->param('quantity') =~ /^\s*(\d+)\s*$/ ) {
 
 my $format = $date_format. ' %T %z (%Z)'; #false laziness w/REAL_cust_pkg.cgi?
 my $start_date = '';
-if( ! $conf->exists('order_pkg-no_start_date') ) {
+if( ! $conf->exists('order_pkg-no_start_date') && $cust_main ) {
   $start_date = $cust_main->next_bill_date;
   $start_date = $start_date ? time2str($format, $start_date) : '';
 }
index 1ae15b9..5b9f63d 100644 (file)
 
   <& /elements/tr-amount_fee.html,
        'amount'             => $amount,
-       'process-pkgpart'    => scalar($conf->config('manual_process-pkgpart')),
+       'process-pkgpart'    => 
+          scalar($conf->config('manual_process-pkgpart', $cust_main->agentnum)),
        'process-display'    => scalar($conf->config('manual_process-display')),
-       'process-skip-first' => $conf->exists('manual_process-skip_first'),
+       'process-skip_first' => $conf->exists('manual_process-skip_first'),
        'num_payments'       => scalar($cust_main->cust_pay), 
-       'post_fee_callback'  => $post_fee_callback,
+       'surcharge_percentage' => scalar($conf->config('credit-card-surcharge-percentage')),
   &>
 
   <& /elements/tr-select-discount_term.html,
@@ -78,7 +79,7 @@
     </TR>
 
     <& /elements/location.html,
-                  'object'         => $cust_main, #XXX errors???
+                  'object'         => $cust_main->bill_location,
                   'no_asterisks'   => 1,
                   'address1_label' => emt('Card billing address'),
     &>
@@ -251,6 +252,10 @@ my $custnum = $1;
 my $cust_main = qsearchs( 'cust_main', { 'custnum'=>$custnum } );
 die "unknown custnum $custnum" unless $cust_main;
 
+my $location = $cust_main->bill_location;
+# no proper error handling on this anyway, but when we have it,
+# remember to repopulate fields in $location
+
 my $balance = $cust_main->balance;
 
 my $payinfo = '';
@@ -269,19 +274,6 @@ if ( $balance > 0 ) {
   $amount = $balance;
 }
 
-my $post_fee_callback = sub {
-  my( $amountref ) = @_;
-
-  return unless $$amountref > 0;
-
-  my $conf = new FS::Conf;
-
-  my $cc_surcharge_pct = $conf->config('credit-card-surcharge-percentage');
-  $$amountref += $$amountref * $cc_surcharge_pct/100 if $cc_surcharge_pct > 0;
-
-  $$amountref = sprintf("%.2f", $$amountref);
-};
-
 my $payunique = "webui-payment-". time. "-$$-". rand() * 2**32;
 
 </%init>
diff --git a/httemplate/misc/process/void-cust_bill.html b/httemplate/misc/process/void-cust_bill.html
new file mode 100755 (executable)
index 0000000..899901a
--- /dev/null
@@ -0,0 +1,26 @@
+%if ( $error ) {
+%  $cgi->param('error', $error);
+<% $cgi->redirect(popurl(1). "void-cust_bill.html?". $cgi->query_string ) %>
+%} else {
+<& /elements/header-popup.html, 'Invoice voided' &>
+<SCRIPT TYPE="text/javascript">
+  window.top.location.reload();
+</SCRIPT>
+</BODY></HTML>
+%}
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Void invoices');
+
+#untaint invnum
+$cgi->param('invnum') =~ /^(\d+)$/ || die "Illegal invnum";
+my $invnum = $1;
+
+my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
+
+my $custnum = $cust_bill->custnum;
+
+my $error = $cust_bill->void( $cgi->param('reason') );
+
+</%init>
index 672fad8..e439282 100755 (executable)
@@ -99,8 +99,6 @@ my(%ticketmap, %ticket, %customers);
 my $title = 'Assign Time Worked';
 tie %ticketmap, 'Tie::IxHash';
 
-RT::Init();
-
 my $CurrentUser = RT::CurrentUser->new();
 $CurrentUser->LoadByName($FS::CurrentUser::CurrentUser->username);
 
diff --git a/httemplate/misc/unvoid-cust_bill_void.html b/httemplate/misc/unvoid-cust_bill_void.html
new file mode 100755 (executable)
index 0000000..f614165
--- /dev/null
@@ -0,0 +1,25 @@
+%if ( $error ) {
+%  errorpage($error);
+%} else {
+%   my $show = $curuser->default_customer_view =~ /^(jumbo|payment_history)$/
+%                ? ''
+%                : ';show=payment_history';
+<% $cgi->redirect($p. "view/cust_main.cgi?custnum=$custnum$show" ) %>
+%}
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('Unvoid invoices');
+
+#untaint invnum
+$cgi->param('invnum') =~ /^(\d+)$/ || die "Illegal invnum";
+my $invnum = $1;
+
+my $cust_bill_void = qsearchs('cust_bill_void', { 'invnum' => $invnum } );
+my $custnum = $cust_bill_void->custnum;
+
+my $error = $cust_bill_void->unvoid;
+
+</%init>
index 91fe1c2..4726ee5 100755 (executable)
@@ -6,7 +6,7 @@
 <%init>
 
 die "access denied"
-  unless $FS::CurrentUser::CurrentUser->access_right('Unvoid');
+  unless $FS::CurrentUser::CurrentUser->access_right('Unvoid payments');
 
 #untaint paynum
 my($query) = $cgi->keywords;
diff --git a/httemplate/misc/void-cust_bill.html b/httemplate/misc/void-cust_bill.html
new file mode 100644 (file)
index 0000000..1608fd0
--- /dev/null
@@ -0,0 +1,45 @@
+<& /elements/header-popup.html, mt('Void invoice') &>
+
+<% include('/elements/error.html') %>
+
+<% emt('Are you sure you want to void this invoice?') %>
+<BR><BR>
+
+<% emt("Invoice #[_1] ([_2])",$cust_bill->display_invnum, $money_char. $cust_bill->owed) %>
+<BR><BR>
+
+<FORM METHOD="POST" ACTION="process/void-cust_bill.html">
+<INPUT TYPE="hidden" NAME="invnum" VALUE="<% $invnum %>">
+
+<% ntable("#cccccc", 2) %>
+<TR>
+  <TD ALIGN="right">Reason</TD>
+  <TD><INPUT TYPE="text" NAME="reason" VALUE="<% $cgi->param('reason') |h %>"></TD>
+</TR>
+
+</TABLE>
+
+<BR>
+<CENTER>
+<BUTTON TYPE="submit">Yes, void invoice</BUTTON>&nbsp;&nbsp;&nbsp;\
+<BUTTON TYPE="button" onClick="parent.cClick();">No, do not void invoice</BUTTON>
+</CENTER>
+
+</FORM>
+</BODY>
+</HTML>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Void invoices');
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+#untaint invnum
+$cgi->param('invnum') =~ /^(\d+)$/ || die "Illegal invnum";
+my $invnum = $1;
+
+my $cust_bill = qsearchs('cust_bill',{'invnum'=>$invnum});
+
+</%init>
index 7b484e9..31b7a62 100755 (executable)
@@ -12,7 +12,7 @@ my $paynum = $1;
 
 my $cust_pay = qsearchs('cust_pay',{'paynum'=>$paynum});
 
-my $right = 'Regular void';
+my $right = 'Void payments';
 $right = 'Credit card void' if $cust_pay->payby eq 'CARD';
 $right = 'Echeck void'      if $cust_pay->payby eq 'CHEK';
 
index 16f7cd2..acf7e70 100644 (file)
@@ -10,7 +10,7 @@
 %
 %   my $string = $cgi->param('arg');
 %   my @cust_main = smart_search( 'search' => $string,
-%                                 'no_fuzzy_on_exact' => 1, #pref?
+%                                 'no_fuzzy_on_exact' => ! $FS::CurrentUser::CurrentUser->option('enable_fuzzy_on_exact'),
 %                               );
 %   my $return = [ map [ $_->custnum,
 %                        $_->name,
index 932cf1a..c4fef03 100644 (file)
@@ -50,6 +50,7 @@ unless ( $error ) { # if ($access_user) {
   #XXX autogen
   my @paramlist = qw( locale menu_position default_customer_view 
                       spreadsheet_format mobile_menu
+                      enable_fuzzy_on_exact
                       disable_html_editor disable_enter_submit_onetimecharge
                       email_address
                       snom-ip snom-username snom-password
index 9ebf2f1..575b804 100644 (file)
@@ -90,7 +90,14 @@ Interface
       </SELECT>
     </TD>
   </TR>
+
+ <TR>
+    <TH ALIGN="right" COLSPAN=1>Enable approximate customer searching even when an exact match is found: </TH>
+    <TD ALIGN="left" COLSPAN=2>
+      <INPUT TYPE="checkbox" NAME="enable_fuzzy_on_exact" VALUE="1" <% $curuser->option('enable_fuzzy_on_exact') ? 'CHECKED' : '' %>>
+    </TD>
+  </TR>
+
   <TR>
     <TH ALIGN="right" COLSPAN=1>Disable HTML editor for customer notes: </TH>
     <TD ALIGN="left" COLSPAN=2>
index 250e718..6f5fcdf 100755 (executable)
@@ -1,33 +1,24 @@
-% unless ( $type eq 'xml' ) {
-<% include( '/elements/header.html', 'FCC Form 477 Results') %>
-%}else{
+% if ( $type eq 'xml' ) {
 <?xml version="1.0" encoding="ISO-8859-1"?>
 <Form_477_submission xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://specialreports.fcc.gov/wcb/Form477/XMLSchema-instance/form_477_upload_Schema.xsd" >
-%}
-% if ( $type eq 'html' || $type eq 'html-print' ) {
+% } else { #html
+<& /elements/header.html, "FCC Form 477 Results - $state" &>
 <TABLE WIDTH="100%">
-  <TR><TD></TD>
-%}elsif ( $type eq 'xml' ) {
-%}
-%           unless ( $type eq 'html-print' || $type eq 'xml' ) {
+  <TR>
+    <TD></TD>
+    <TD ALIGN="right" CLASS="noprint">
+      Download full results<BR>
+%   $cgi->param('_type', 'xml');
+      as <A HREF="<% $cgi->self_url %>">XML file</A><BR>
 
-              <TD ALIGN="right">
+%   $cgi->param('_type', 'html-print');
+      as <A HREF="<% $cgi->self_url %>">printable copy</A>
 
-                Download full results<BR>
-%               $cgi->param('_type', 'xml');
-                as <A HREF="<% $cgi->self_url %>">XML file</A><BR>
-
-%               $cgi->param('_type', 'html-print');
-                as <A HREF="<% $cgi->self_url %>">printable copy</A>
-
-              </TD>
-%             $cgi->param('_type', $type );
-%           }
-% if ( $type eq 'html' || $type eq 'html-print' ) {
+    </TD>
+%   $cgi->param('_type', $type );
   </TR>
 </TABLE>
-%}elsif ( $type eq 'xml' ) {
-%}
+% } #html
 % foreach my $part ( @parts ) {
 %   if ( $part{$part} ) {
 %
@@ -47,8 +38,8 @@
 %         if ( $type eq 'xml' ) {
 <<% 'Part_IA_'. chr(65 + $tech) %>>
 %         }
-<% include( "477part${part}_summary.html", 'tech_code' => $tech, 'url' => $url ) %>
-<% include( "477part${part}_detail.html", 'tech_code' => $tech, 'url' => $url ) %>
+<& "477part${part}_summary.html", 'tech_code' => $tech, 'url' => $url &>
+<& "477part${part}_detail.html", 'tech_code' => $tech, 'url' => $url &>
 %         if ( $type eq 'xml' ) {
 </<% 'Part_IA_'. chr(65 + $tech) %>>
 %         }
@@ -58,7 +49,7 @@
 <<% 'Part_'. $part %>>
 %       }
 %       my $url = &{$url_mangler}($part);
-<% include( "477part${part}.html", 'url' => $url ) %>
+<& "477part${part}.html", 'url' => $url &>
 %       if ( $type eq 'xml' ) {
 </<% 'Part_'. $part %>>
 %       }
 %   }
 % }
 %
-% if ( $type eq 'html' || $type eq 'html-print' ) {
-<% include( '/elements/footer.html') %>
-%}elsif ( $type eq 'xml' ) {
+% if ( $type eq 'xml' ) {
 </Form_477_submission>
-%}
+% } else {
+<& /elements/footer.html &>
+% }
 <%init>
 
 my $curuser = $FS::CurrentUser::CurrentUser;
@@ -78,6 +69,9 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
   unless $curuser->access_right('List packages');
 
+my $state = uc($cgi->param('state'));
+$state =~ /^[A-Z]{2}$/ or die "illegal state: $state";
+
 my %part = map { $_ => 1 } grep { /^\w+$/ } $cgi->param('part');
 my $type = $cgi->param('_type') || 'html';
 my $xlsname = '477report';
index 2eca107..66f3a86 100755 (executable)
@@ -23,9 +23,10 @@ die "access denied"
 my %opt = @_;
 my %search_hash = ();
   
-for ( qw(agentnum magic) ) {
+for ( qw(agentnum magic state) ) {
   $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
 }
+$search_hash{'country'} = 'US';
 
 $search_hash{'classnum'} = [ $cgi->param('classnum') ];
 
index ecacaef..f5c2bc2 100755 (executable)
@@ -40,9 +40,10 @@ die "access denied"
 my %opt = @_;
 my %search_hash = ();
   
-for ( qw(agentnum magic) ) {
+for ( qw(agentnum magic state) ) {
   $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
 }
+$search_hash{'country'} = 'US';
 $search_hash{'classnum'} = [ $cgi->param('classnum') ];
 
 my @column_option = grep { /^\d+$/ } $cgi->param('part1_column_option')
index 9b363ad..d2cc8c3 100755 (executable)
@@ -22,9 +22,10 @@ die "access denied"
 my $html_init = '<H2>Part IIA</H2>';
 my %search_hash = ();
   
-for ( qw(agentnum magic) ) {
+for ( qw(agentnum magic state) ) {
   $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
 }
+$search_hash{'country'} = 'US';
 $search_hash{'classnum'} = [ $cgi->param('classnum') ];
 
 my @row_option = grep { /^\d+$/ } $cgi->param('part2a_row_option')
index 94aa818..c58310d 100755 (executable)
@@ -1,17 +1,44 @@
-<% include( 'elements/search.html',
-                  'html_init'        => $html_init,
-                  'name'             => 'lines',
-                  'query'            => $query,
-                  'count_query'      => 'SELECT 11',
-                  'really_disable_download' => 1,
-                  'disable_download' => 1,
-                  'nohtmlheader'     => 1,
-                  'disable_total'    => 1,
-                  'header'           => [ @headers ],
-                  'xml_elements'     => [ @xml_elements ],
-                  'fields'           => [ @fields ],
-              )
-%>
+% if ( $cgi->param('_type') eq 'xml' ) {
+%   my @cols = qw(a b c);
+%   for ( my $row = 0; $row < scalar(@rows); $row++ ) {
+%     for my $col (0..2) {
+%       if ( exists($data[$col][$row]) ) {
+<PartII_<% $row %><% $cols[$col] %>>
+%       }
+</PartII_<% $row %><% $cols[$col] %>>
+%     } #for $col
+%   } #for $row
+% } else { # HTML mode
+% # fake up the search-html.html header
+<H2>Part IIB</H2>
+<TABLE>
+  <TR><TD VALIGN="bottom"><BR></TD></TR>
+  <TR><TD COLSPAN=2>
+  <TABLE CLASS="grid" CELLSPACING=0 STYLE="border: 1px solid #cccccc;" BGCOLOR="#cccccc">
+    <TR>
+% foreach (@headers) {
+      <TH class="grid"><% $_ %></TH>
+% }
+    </TR>
+% my @bgcolor = ('eeeeee','ffffff');
+% my $row = 0;
+% foreach my $rowhead (@rows) {
+    <TR> 
+      <TD CLASS="grid" BGCOLOR="#<% $bgcolor[$row % 2] %>"><% $rowhead %></TD>
+%     for my $col (0..2) {
+      <TD CLASS="grid" BGCOLOR="#<% $bgcolor[$row % 2] %>">
+%       if ( exists($data[$col][$row]) ) {
+      <% $data[$col][$row] %>
+%       }
+      </TD>
+%     } # for $col
+    </TR>
+%   $row++;
+% } #for $rowhead
+  </TABLE>
+  </TD></TR>
+</TABLE>
+% } #XML/HTML
 <%init>
 
 my $curuser = $FS::CurrentUser::CurrentUser;
@@ -19,67 +46,89 @@ my $curuser = $FS::CurrentUser::CurrentUser;
 die "access denied"
   unless $curuser->access_right('List packages');
 
-my $html_init = '<H2>Part IIB</H2>';
 my %search_hash = ();
-  
-for ( qw(agentnum magic) ) {
-  $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
-}
-$search_hash{'classnum'} = [ $cgi->param('classnum') ];
-
-my @row_option = grep { /^\d+$/ } $cgi->param('part2b_row_option')
-  if $cgi->param('part2b_row_option');
-
-# fudge in 2nd row
-unshift @row_option, $row_option[0];
-
-my $query = 'SELECT '. join(' UNION SELECT ', 1..8);
-
-my $total_count = 0;
-my $column_value = sub {
-  my $row = shift;
-
-  my @report_option = ( $row_option[$row - 1] || '' );
 
-  my $sql_query = FS::cust_pkg->search(
-    { %search_hash, 'report_option' => join(',', @report_option) }
-  );
-
-  my $count_sql = delete($sql_query->{'count_query'});
-  if ( $row == 2 ) {
-    $count_sql =~ s/COUNT\(\*\) FROM/sum(COALESCE(CASE WHEN cust_main.company IS NULL OR cust_main.company = '' THEN   CASE WHEN part_pkg.fcc_ds0s IS NOT NULL AND part_pkg.fcc_ds0s > 0 THEN part_pkg.fcc_ds0s WHEN pkg_class.fcc_ds0s IS NOT NULL AND pkg_class.fcc_ds0s > 0 THEN pkg_class.fcc_ds0s ELSE 0 END   ELSE 0 END, 0) ) FROM/
-      or die "couldn't parse count_sql";
-  } else {
-    $count_sql =~ s/COUNT\(\*\) FROM/sum(COALESCE(CASE WHEN part_pkg.fcc_ds0s IS NOT NULL AND part_pkg.fcc_ds0s > 0 THEN part_pkg.fcc_ds0s WHEN pkg_class.fcc_ds0s IS NOT NULL AND pkg_class.fcc_ds0s > 0 THEN pkg_class.fcc_ds0s ELSE 0 END, 0)) FROM/
-      or die "couldn't parse count_sql";
-  }
-
-  my $count_sth = dbh->prepare($count_sql)
-    or die "Error preparing $count_sql: ". dbh->errstr;
-  $count_sth->execute
-    or die "Error executing $count_sql: ". $count_sth->errstr;
-  my $count_arrayref = $count_sth->fetchrow_arrayref;
-  my $count = $count_arrayref->[0];
+$search_hash{'agentnum'} = $cgi->param('agentnum');
+$search_hash{'state'}    = $cgi->param('state');
+$search_hash{'classnum'} = [ $cgi->param('classnum') ];
+$search_hash{'status'}   = 'active';
 
-  $total_count = $count if $row == 1;
-  $count = sprintf('%.2f', $total_count ? 100*$count/$total_count : 0)
-    if $row != 1;
+my @row_option;
+foreach ($cgi->param('part2b_row_option')) {
+  push @row_option, (/^\d+$/ ? $_ : undef);
+}
 
-  return "$count";
+my $is_residential = "AND COALESCE(cust_main.company, '') = ''";
+my $has_report_option = sub {
+  map {
+    defined($row_option[$_]) ?
+    "AND EXISTS(
+      SELECT 1 FROM part_pkg_option 
+      WHERE part_pkg_option.pkgpart = part_pkg.pkgpart
+      AND optionname = 'report_option_" . $row_option[$_]."'
+      AND optionvalue = '1'
+    )" : 'AND FALSE'
+  } @_
 };
 
-my @headers = (
-  '',
-  'without broadband',
-  'with broadband',
-  'wholesale',
+# an arrayref for each column
+my @data;
+# get the skeleton of the query
+my $sql_query = FS::cust_pkg->search(\%search_hash);
+my $from_where = $sql_query->{'count_query'};
+$from_where =~ s/^SELECT COUNT\(\*\) //;
+# columns 1 and 2
+my $query_ds0 = "SELECT SUM(COALESCE(part_pkg.fcc_ds0s, pkg_class.fcc_ds0s, 0))
+  $from_where";
+# column 3
+my $query_custnum = "SELECT COUNT(DISTINCT cust_pkg.custnum) $from_where";
+
+my @base_queries = ($query_ds0, $query_ds0, $query_custnum);
+my @col_conds = (
+  # column 1
+  [
+    '',
+    $is_residential,
+    $has_report_option->(0), # nomadic
+  ],
+  # column 2
+  [
+    '',
+    $is_residential,
+    $has_report_option->(0..5),
+  ],
+  # column 3
+  [
+    ''
+  ]
 );
 
-my @xml_elements = (
-  sub { my $row = shift; my $rownum = $row->[0] + 1; "PartII_${rownum}a" },
-  sub { my $row = shift; my $rownum = $row->[0] + 1; "PartII_${rownum}b" },
-  sub { my $row = shift; my $rownum = $row->[0] + 1; "PartII_${rownum}c" },
-);
+my $col = 0;
+foreach (@col_conds) {
+  my @col_data;
+  my $row = 0;
+  foreach my $cond (@{ $col_conds[$col] }) {
+    # three parts: the select expression, the VoIP class (column selection),
+    # and the row selection
+    my $query = $base_queries[$col] . 
+                " AND part_pkg.fcc_voip_class = '".($col+1)."'
+                $cond";
+    my $count = FS::Record->scalar_sql($query) || 0;
+    if ( $row == 0 ) {
+      $col_data[$row] = $count; # the raw count
+    } else {
+      if ( $col_data[0] == 0 ) {
+        $col_data[$row] = ''; # show nothing in this row, then
+      } else {
+        $col_data[$row] = sprintf('%.2f', 100 * $count / $col_data[0]) . '%';
+      }
+    } #if $row == 0
+    $row++;
+  }
+  $data[$col] = \@col_data;
+  $col++;
+}
+
 
 my @rows = (
   'total number',
@@ -92,12 +141,11 @@ my @rows = (
   '% other broadband',
 );
 
-my @fields = (
-  sub { my $row = shift; $rows[$row->[0] - 1]; },
-  sub { 0; },
-  sub { my $row = shift; &{$column_value}($row->[0]); },
-  sub { 0; },
+my @headers = (
+  '',
+  'without broadband',
+  'with broadband',
+  'wholesale',
 );
 
-shift @fields if $cgi->param('_type') eq 'xml';
 </%init>
index 0987fea..2fd5119 100755 (executable)
@@ -10,7 +10,7 @@
                   'no_field_elements' => 1,
                   'fields'            => [ 'zip' ],
                   'url'               => $opt{url} || '',
-                  'disable_download'  => 1,
+                  'really_disable_download'  => 1,
 
               )
 %>
@@ -27,9 +27,10 @@ my %search_hash = ();
 my @sql_query = ();
 my @count_query = ();
   
-for ( qw(agentnum magic) ) {
+for ( qw(agentnum magic state) ) {
   $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
 }
+$search_hash{'country'} = 'US';
 $search_hash{'classnum'} = [ $cgi->param('classnum') ];
 $search_hash{report_option} = $cgi->param('partv_report_option')
   if $cgi->param('partv_report_option');
index 4d1fb21..8425c4b 100755 (executable)
@@ -23,6 +23,7 @@
                   'links'           => \@links,
                   'url'             => $opt{url} || '',
                   'xml_row_element' => 'Datarow',
+                  'really_disable_download' => 1,
               )
 %>
 <%init>
@@ -80,9 +81,10 @@ push @fields,
 my %search_hash = ();
 my @sql_query = ();
   
-for ( qw(agentnum magic) ) {
+for ( qw(agentnum magic state) ) {
   $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
 }
+$search_hash{'country'} = 'US';
 $search_hash{'classnum'} = [ $cgi->param('classnum') ]
   if grep { $_ eq 'classnum' } $cgi->param;
 
@@ -115,10 +117,10 @@ foreach my $row ( @row_option ) {
       );
       my $extracolumns = "$rowcount AS upload, $columncount AS download, $tech_code as technology_code";
       my $percent = "CASE WHEN count(*) > 0 THEN 100-100*cast(count(cust_main.company) as numeric)/cast(count(*) as numeric) ELSE cast(0 as numeric) END AS residential";
-      $sql_query->{select} = "count(*) AS quantity, $extracolumns, censustract, $percent";
+      $sql_query->{select} = "count(*) AS quantity, $extracolumns, cust_location.censustract, $percent";
       $sql_query->{order_by} =~ /^(.*)(ORDER BY pkgnum)(.*)$/s
         or die "couldn't parse order_by";
-      $sql_query->{order_by} = "$1 GROUP BY censustract $3";
+      $sql_query->{order_by} = "$1 GROUP BY cust_location.censustract $3";
       push @sql_query, $sql_query;
     }
     $columncount++;
@@ -131,7 +133,8 @@ my $count_query = 'SELECT count(*) FROM ( ('.
       map { my $addl_from = $_->{addl_from};
             my $extra_sql = $_->{extra_sql};
             my $order_by  = $_->{order_by};
-            "SELECT censustract from cust_pkg $addl_from $extra_sql $order_by";
+            "SELECT cust_location.censustract from cust_pkg $addl_from 
+            $extra_sql $order_by";
           }
       @sql_query
    ). ') ) AS foo';
index 1a46b00..4c0fa4a 100644 (file)
@@ -3,25 +3,10 @@
                  'name'        => emt('line items'),
                  'query'       => $query,
                  'count_query' => $count_query,
-                 'count_addl'  => [ $money_char. '%.2f total',
-                                    $unearned ? ( $money_char. '%.2f unearned revenue' ) : (),
-                                  ],
+                 'count_addl'  => \@total_desc,
                  'header'      => [
                    emt('Description'),
-                   ( $unearned
-                     ? ( emt('Unearned'), 
-                         emt('Owed'), # useful in 'paid' mode?
-                         emt('Payment date') )
-                     : ( emt('Setup charge') )
-                   ),
-                   ( $use_usage eq 'usage'
-                     ? emt('Usage charge')
-                     : emt('Recurring charge')
-                   ),
-                   ( $unearned
-                     ? ( emt('Charge start'), emt('Charge end') )
-                     : ()
-                   ),
+                   @peritem_desc,
                    emt('Invoice'),
                    emt('Date'),
                    FS::UI::Web::cust_header(),
                        },
                    #strikethrough or "N/A ($amount)" or something these when
                    # they're not applicable to pkg_tax search
-                   sub { my $cust_bill_pkg = shift;
-                         if ( $unearned ) {
-
-                           sprintf($money_char.'%.2f', 
-                             $cust_bill_pkg->unearned_revenue)
-
-                         } else {
-                           sprintf($money_char.'%.2f', $cust_bill_pkg->setup );
-                         }
-                       },
-                   ( $unearned
-                     ? ( $owed_sub, $payment_date_sub, )
-                     : ()
-                   ),
-                   sub { my $row = shift;
-                         my $value = 0;
-                         if ( $use_usage eq 'recurring' or $unearned ) {
-                           $value = $row->recur - $row->usage;
-                         } elsif ( $use_usage eq 'usage' ) {
-                           $value = $row->usage;
-                         } else {
-                           $value = $row->recur;
-                         }
-                         sprintf($money_char.'%.2f', $value );
-                       },
-                   ( $unearned
-                     ? ( sub { time2str('%b %d %Y', shift->sdate ) },
-                       # shift edate back a day
-                       # 82799 = 3600*23 - 1
-                       # (to avoid skipping a day during DST)
-                         sub { time2str('%b %d %Y', shift->edate - 82799 ) },
-                       )
-                     : ()
-                   ),
+                   @peritem_sub,
                    'invnum',
                    sub { time2str('%b %d %Y', shift->_date ) },
                    \&FS::UI::Web::cust_fields,
                  ],
                  'sort_fields' => [
                    '',
-                   'setup', #broken in $unearned case i guess
-                   ( $unearned ? ('', '') : () ),
-                   ( $use_usage eq 'recurring' or $unearned
-                        ? 'recur - usage' :
-                     $use_usage eq 'usage' 
-                        ? 'usage'
-                        : 'recur'
-                   ),
-                   ( $unearned ? ('sdate', 'edate') : () ),
+                   @peritem,
                    'invnum',
                    '_date',
                  ],
                  'links'       => [
                    #'',
                    '',
-                   '',
-                   ( $unearned ? ( '', '' ) : () ),
-                   '',
-                   ( $unearned ? ( '', '' ) : () ),
+                   @peritem_null,
                    $ilink,
                    $ilink,
                    ( map { $_ ne 'Cust. Status' ? $clink : '' }
                    ),
                  ],
                  #'align' => 'rlrrrc'.FS::UI::Web::cust_aligns(),
-                 'align' => 'lr'.
-                            ( $unearned ? 'rc' : '' ).
-                            'r'.
-                            ( $unearned ? 'cc' : '' ).
+                 'align' => 'l'.
+                            $peritem_align.
                             'rc'.
                             FS::UI::Web::cust_aligns(),
                  'color' => [ 
                               #'',
                               '',
-                              '',
-                              ( $unearned ? ( '', '' ) : () ),
-                              '',
-                              ( $unearned ? ( '', '' ) : () ),
+                              @peritem_null,
                               '',
                               '',
                               FS::UI::Web::cust_colors(),
                  'style' => [ 
                               #'',
                               '',
-                              '',
-                              ( $unearned ? ( '', '' ) : () ),
-                              '',
-                              ( $unearned ? ( '', '' ) : () ),
+                              @peritem_null,
                               '',
                               '',
                               FS::UI::Web::cust_styles(),
                             ],
 &>
-<%init>
+<%doc>
+
+Output parameters:
+- distribute: Boolean.  If true, recurring fees will be "prorated" for the 
+  portion of the package date range (sdate-edate) that falls within the date
+  range of the report.  Line items will be limited to those for which this 
+  portion is > 0.  This disables filtering on invoice date.
+
+- use_usage: Separate usage (cust_bill_pkg_detail records) from
+  recurring charges.  If set to "usage", will show usage instead of 
+  recurring charges.  If set to "recurring", will deduct usage and only
+  show the flat rate charge.  If not passed, the "recurring charge" column
+  will include usage charges also.
+
+Filtering parameters:
+- begin, end: Date range.  Applies to invoice date, not necessarily package
+  date range.  But see "distribute".
+
+- status: Customer status (active, suspended, etc.).  This will filter on 
+  _current_ customer status, not status at the time the invoice was generated.
+
+- agentnum: Filter on customer agent.
+
+- refnum: Filter on customer reference source.
+
+- classnum: Filter on package class.
+
+- use_override: Apply "classnum" and "taxclass" filtering based on the 
+  override (bundle) pkgpart, rather than always using the true pkgpart.
+
+- nottax: Limit to items that are not taxes (pkgnum > 0).
+
+- istax: Limit to items that are taxes (pkgnum == 0).
+
+- taxnum: Limit to items whose tax definition matches this taxnum.
+  With "nottax" that means items that are subject to that tax;
+  with "istax" it's the tax charges themselves.  Can be specified 
+  more than once to include multiple taxes.
+
+- country, state, county, city: Limit to items whose tax location 
+  matches these fields.  If "nottax" it's the tax location of the package;
+  if "istax" the location of the tax.
+
+- taxname, taxnameNULL: With "nottax", limit to items whose tax location
+  matches a tax with this name.  With "istax", limit to items that have
+  this tax name.  taxnameNULL is equivalent to "taxname = '' OR taxname 
+  = 'Tax'".
+
+- out: With "nottax", limit to items that don't match any tax definition.
+  With "istax", find tax items that are unlinked to their tax definitions.
+  Current Freeside (> July 2012) always creates tax links, but unlinked
+  items may result from an incomplete upgrade of legacy data.
+
+- locationtaxid: With "nottax", limit to packages matching this 
+  tax_rate_location ID; with "tax", limit to taxes generated from that 
+  location.
+
+- taxclass: Filter on package taxclass.
+
+- taxclassNULL: With "nottax", limit to items that would be subject to the
+  tax with taxclass = NULL.  This doesn't necessarily mean part_pkg.taxclass
+  is NULL; it also includes taxclasses that don't have a tax in this region.
+
+- itemdesc: Limit to line items with this description.  Note that non-tax
+  packages usually have a description of NULL.  (Deprecated.)
 
-#LOTS of false laziness below w/cust_credit_bill_pkg.cgi
+- report_group: Can contain '=' or '!=' followed by a string to limit to 
+  line items where itemdesc starts with, or doesn't start with, the string.
+
+- cust_tax: Limit to customers who are tax-exempt.  If "taxname" is also
+  specified, limit to customers who are also specifically exempt from that 
+  tax.
+
+- pkg_tax: Limit to packages that are tax-exempt, and only include the 
+  exempt portion (setup, recurring, or both) when calculating totals.
+
+- taxable: Limit to packages that are subject to tax, i.e. where a
+  cust_bill_pkg_tax_location record exists.
+
+- credit: Limit to line items that received a credit application.  The
+  amount of the credit will also be shown.
+
+</%doc>
+<%init>
 
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
 my $conf = new FS::Conf;
-
-my $unearned = '';
-my $unearned_mode = '';
-my $unearned_base = '';
-my $unearned_sql = '';
+my $money_char = $conf->config('money_char') || '$';
 
 my @select = ( 'cust_bill_pkg.*', 'cust_bill._date' );
+my @total = ( 'COUNT(*)', 'SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)');
+my @total_desc = ( '%d line items', $money_char.'%.2f total' ); # sprintf strings
+my @peritem = ( 'setup', 'recur' );
+my @peritem_desc = ( 'Setup charge', 'Recurring charge' );
 my ($join_cust, $join_pkg ) = ('', '');
+my $use_usage;
+
+# valid in both the tax and non-tax cases
+$join_cust = 
+  " LEFT JOIN cust_bill USING (invnum)
+    LEFT JOIN cust_main USING (custnum)
+  ";
 
-#here is the agent virtualization
+#agent virtualization
 my $agentnums_sql =
   $FS::CurrentUser::CurrentUser->agentnums_sql( 'table' => 'cust_main' );
 
 my @where = ( $agentnums_sql );
 
+# date range
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 
-if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
-  push @where, FS::cust_main->cust_status_sql . " = '$1'";
-}
-
 if ( $cgi->param('distribute') == 1 ) {
   push @where, "sdate <= $ending",
                "edate >  $beginning",
@@ -167,456 +185,371 @@ else {
                "cust_bill._date <= $ending";
 }
 
+# status
+if ( $cgi->param('status') =~ /^([a-z]+)$/ ) {
+  push @where, FS::cust_main->cust_status_sql . " = '$1'";
+}
+
+# agentnum
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
 
+# refnum
 if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.refnum = $1";
 }
 
-#classnum
-# not specified: all classes
-# 0: empty class
-# N: classnum
-my $use_override = $cgi->param('use_override');
-if ( $cgi->param('classnum') =~ /^(\d+)$/ ) {
-  my $comparison = '';
-  if ( $1 == 0 ) {
-    $comparison = "IS NULL";
-  } else {
-    $comparison = "= $1";
-  }
-
-  if ( $use_override ) {
-    push @where, "(
-      part_pkg.classnum $comparison AND pkgpart_override IS NULL OR
-      override.classnum $comparison AND pkgpart_override IS NOT NULL
-    )";
-  } else {
-    push @where, "part_pkg.classnum $comparison";
-  }
-}
+# the non-tax case
+if ( $cgi->param('nottax') ) {
 
-if ( $cgi->param('taxclass')
-     && ! $cgi->param('istax')  #no part_pkg.taxclass in this case
-                                #(should we save a taxclass or a link to taxnum
-                                # in cust_bill_pkg or something like
-                                # cust_bill_pkg_tax_location?)
-   )
-{
-
-  #override taxclass when use_override is specified?  probably
-  #if ( $use_override ) {
-  #
-  #  push @where,
-  #    ' ( '. join(' OR ',
-  #                  map {
-  #                        ' (    part_pkg.taxclass = '. dbh->quote($_).
-  #                        '      AND pkgpart_override IS NULL '.
-  #                        '   OR '.
-  #                        '      override.taxclass = '. dbh->quote($_).
-  #                        '      AND pkgpart_override IS NOT NULL '.
-  #                        ' ) '
-  #                      }
-  #                      $cgi->param('taxclass')
-  #               ).
-  #    ' ) ';
-  #
-  #} else {
-
-    push @where, ' part_pkg.taxclass IN ( '.
-                   join(', ', map dbh->quote($_), $cgi->param('taxclass') ).
-                 ' ) ';
-
-  #}
+  push @where, 'cust_bill_pkg.pkgnum > 0';
 
-}
+  # then we want the package and its definition
+  $join_pkg = 
+' LEFT JOIN cust_pkg      USING (pkgnum) 
+  LEFT JOIN part_pkg      USING (pkgpart)';
 
-my @loc_param = qw( district city county state country );
+  my $part_pkg = 'part_pkg';
+  if ( $cgi->param('use_override') ) {
+    # still need the real part_pkg for tax applicability, 
+    # so alias this one
+    $join_pkg .= " LEFT JOIN part_pkg AS override ON (
+    COALESCE(cust_bill_pkg.pkgpart_override, cust_pkg.pkgpart, 0) = part_pkg.pkgpart
+    )";
+    $part_pkg = 'override';
+  }
+  push @select, 'part_pkg.pkg'; # or should this use override?
 
-if ( $cgi->param('out') ) {
+  my @tax_where; # will go into a subquery
+  my @exempt_where; # will also go into a subquery
 
-  my ( $loc_sql, @param ) = FS::cust_pkg->location_sql( 'ornull' => 1 );
-  while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-    $loc_sql =~ s/\?/'cust_main_county.'.shift(@param)/e;
+  # classnum (of override pkgpart if applicable)
+  # not specified: all classes
+  # 0: empty class
+  # N: classnum
+  if ( $cgi->param('classnum') =~ /^(\d+)$/ ) {
+    push @where, "COALESCE($part_pkg.classnum, 0) = $1";
   }
 
-  $loc_sql =~ s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g
-    if $cgi->param('istax');
-
-  push @where, "
-    0 = (
-          SELECT COUNT(*) FROM cust_main_county
-           WHERE cust_main_county.tax > 0
-             AND $loc_sql
-        )
-  ";
+  # taxclass
+  if ( $cgi->param('taxclassNULL') ) {
+    # a little different from 'taxclass' in that it applies to the
+    # effective taxclass, not the real one
+    push @tax_where, 'cust_main_county.taxclass IS NULL'
+  } elsif ( $cgi->param('taxclass') ) {
+    push @tax_where, "$part_pkg.taxclass IN (" .
+                 join(', ', map {dbh->quote($_)} $cgi->param('taxclass') ).
+                 ')';
+  }
 
-  #not linked to by anything, but useful for debugging "out of taxable region"
-  if ( grep $cgi->param($_), @loc_param ) {
+  if ( $cgi->param('exempt_cust') eq 'Y' ) {
+    # tax-exempt customers
+    push @exempt_where, "(exempt_cust = 'Y' OR exempt_cust_taxname = 'Y')";
 
-    my %ph = map { $_ => dbh->quote( scalar($cgi->param($_)) ) } @loc_param;
+  } elsif ( $cgi->param('exempt_pkg') eq 'Y' ) { # non-taxable package
+    # non-taxable package charges
+    push @exempt_where, "(exempt_setup = 'Y' OR exempt_recur = 'Y')";
+  }
+  # we don't handle exempt_monthly here
+  
+  if ( $cgi->param('taxname') ) { # specific taxname
+      push @tax_where, 'cust_main_county.taxname = '.
+                        dbh->quote($cgi->param('taxname'));
+  } elsif ( $cgi->param('taxnameNULL') ) {
+      push @tax_where, 'cust_main_county.taxname IS NULL OR '.
+                       'cust_main_county.taxname = \'Tax\'';
+  }
 
-    my ( $loc_sql, @param ) = FS::cust_pkg->location_sql;
-    while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-      $loc_sql =~ s/\?/$ph{shift(@param)}/e;
+  # country:state:county:city:district (may be repeated)
+  # You can also pass a big list of taxnums but that leads to huge URLs.
+  # Note that this means "packages whose tax is in this region", not 
+  # "packages in this region".  It's meant for links from the tax report.
+  if ( $cgi->param('region') ) {
+    my @orwhere;
+    foreach ( $cgi->param('region') ) {
+      my %loc;
+      @loc{qw(country state county city district)} = 
+        split(':', $cgi->param('region'));
+      my $string = join(' AND ',
+            map { 
+              if ( $loc{$_} ) {
+                "$_ = ".dbh->quote($loc{$_});
+              } else {
+                "$_ IS NULL";
+              }
+            } keys(%loc)
+      );
+      push @orwhere, "($string)";
     }
+    push @tax_where, '(' . join(' OR ', @orwhere) . ')' if @orwhere;
+  }
 
-    push @where, $loc_sql;
-
+  # specific taxnums
+  if ( $cgi->param('taxnum') ) {
+    my $taxnum_in = join(',', 
+      grep /^\d+$/, $cgi->param('taxnum')
+    );
+    push @tax_where, "cust_main_county.taxnum IN ($taxnum_in)"
+      if $taxnum_in;
   }
 
-} elsif ( $cgi->param('country') ) {
+  # If we're showing exempt items, we need to find those with 
+  # cust_tax_exempt_pkg records matching the selected taxes.
+  # If we're showing taxable items, we need to find those with 
+  # cust_bill_pkg_tax_location records.  We also need to find the 
+  # exemption records so that we can show the taxable amount.
+  # If we're showing all items, we need the union of those.
+  # If we're showing 'out' (items that aren't region/class taxable),
+  # then we need the set of all items minus the union of those.
 
-  my @counties = $cgi->param('county');
-   
-  if ( scalar(@counties) > 1 ) {
+  my $exempt_sub;
 
-    #hacky, could be more efficient.  care if it is ever used for more than the
-    # tax-report_groups filtering kludge
+  if ( @exempt_where or @tax_where 
+    or $cgi->param('taxable') or $cgi->param('out') )
+  {
+    # process exemption restrictions, including @tax_where
+    my $exempt_sub = 'SELECT SUM(amount) as exempt_amount, billpkgnum 
+    FROM cust_tax_exempt_pkg JOIN cust_main_county USING (taxnum)';
 
-    my $locs_sql =
-      ' ( '. join(' OR ', map {
+    $exempt_sub .= ' WHERE '.join(' AND ', @tax_where, @exempt_where)
+      if (@tax_where or @exempt_where);
 
-          my %ph = ( 'county' => dbh->quote($_),
-                     map { $_ => dbh->quote( $cgi->param($_) ) }
-                       qw( district city state country )
-                   );
+    $exempt_sub .= ' GROUP BY billpkgnum';
 
-          my ( $loc_sql, @param ) = FS::cust_pkg->location_sql;
-          while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-            $loc_sql =~ s/\?/$ph{shift(@param)}/e;
-          }
+    $join_pkg .= " LEFT JOIN ($exempt_sub) AS item_exempt
+    USING (billpkgnum)";
+  }
+  if ( @tax_where or $cgi->param('taxable') or $cgi->param('out') ) { 
+    # process tax restrictions
+    unshift @tax_where,
+      'cust_main_county.tax > 0';
+
+    my $tax_sub = "SELECT invnum, cust_bill_pkg_tax_location.pkgnum
+    FROM cust_bill_pkg_tax_location
+    JOIN cust_bill_pkg AS tax_item USING (billpkgnum)
+    JOIN cust_main_county USING (taxnum)
+    WHERE ". join(' AND ', @tax_where).
+    " GROUP BY invnum, cust_bill_pkg_tax_location.pkgnum";
+
+    $join_pkg .= " LEFT JOIN ($tax_sub) AS item_tax
+    ON (item_tax.invnum = cust_bill_pkg.invnum AND
+        item_tax.pkgnum = cust_bill_pkg.pkgnum)";
+  }
 
-          $loc_sql;
+  # now do something with that
+  if ( @exempt_where ) {
 
-        } @counties
+    push @where,    'item_exempt.billpkgnum IS NOT NULL';
+    push @select,   'item_exempt.exempt_amount';
+    push @peritem,  'exempt_amount';
+    push @peritem_desc, 'Exempt';
+    push @total,    'SUM(exempt_amount)';
+    push @total_desc, "$money_char%.2f tax-exempt";
 
-      ). ' ) ';
+  } elsif ( $cgi->param('taxable') ) {
 
-    push @where, $locs_sql;
+    my $taxable = 'cust_bill_pkg.setup + cust_bill_pkg.recur '.
+                  '- COALESCE(item_exempt.exempt_amount, 0)';
 
-  } else {
+    push @where,    'item_tax.invnum IS NOT NULL';
+    push @select,   "($taxable) AS taxable_amount";
+    push @peritem,  'taxable_amount';
+    push @peritem_desc, 'Taxable';
+    push @total,    "SUM($taxable)";
+    push @total_desc, "$money_char%.2f taxable";
 
-    my %ph = map { $_ => dbh->quote( scalar($cgi->param($_)) ) } @loc_param;
+  } elsif ( $cgi->param('out') ) {
+  
+    push @where,    'item_tax.invnum IS NULL',
+                    'item_exempt.billpkgnum IS NULL';
 
-    my ( $loc_sql, @param ) = FS::cust_pkg->location_sql;
-    while ( $loc_sql =~ /\?/ ) { #easier to do our own substitution
-      $loc_sql =~ s/\?/$ph{shift(@param)}/e;
-    }
+  } elsif ( @tax_where ) {
 
-    push @where, $loc_sql;
+    # union of taxable + all exempt_ cases
+    push @where,
+      '(item_tax.invnum IS NOT NULL OR item_exempt.billpkgnum IS NOT NULL)';
 
   }
-   
-  if ( $cgi->param('istax') ) {
-    if ( $cgi->param('taxname') ) {
-      push @where, 'itemdesc = '. dbh->quote( $cgi->param('taxname') );
-    #} elsif ( $cgi->param('taxnameNULL') {
-    } else {
-      push @where, "( itemdesc IS NULL OR itemdesc = '' OR itemdesc = 'Tax' )";
-    }
-  } elsif ( $cgi->param('nottax') ) {
-    #what can we usefully do with "taxname" ????  look up a class???
-  } else {
-    #warn "neither nottax nor istax parameters specified";
-  }
 
-  if ( $cgi->param('taxclassNULL')
-       && ! $cgi->param('istax')  #no part_pkg.taxclass in this case
-                                  #(see comment above?)
-     )
-  {
-    my %hash = ( 'country' => scalar($cgi->param('country')) );
-    foreach (qw( state county )) {
-      $hash{$_} = scalar($cgi->param($_)) if $cgi->param($_);
-    }
-    my $cust_main_county = qsearchs('cust_main_county', \%hash);
-    die "unknown base region for empty taxclass" unless $cust_main_county;
+  # recur/usage separation
+  $use_usage = $cgi->param('usage');
+  if ( $use_usage eq 'recurring' ) {
 
-    my $same_sql = $cust_main_county->sql_taxclass_sameregion;
-    $same_sql =~ s/taxclass/part_pkg.taxclass/g;
-    push @where, $same_sql if $same_sql;
+    my $recur_no_usage = FS::cust_bill_pkg->charged_sql('', '', no_usage => 1);
+    push @select, "($recur_no_usage) AS recur_no_usage";
+    $peritem[1] = 'recur_no_usage';
+    $total[1] = "SUM(cust_bill_pkg.setup + $recur_no_usage)";
+    $total_desc[1] .= ' (excluding usage)';
+
+  } elsif ( $use_usage eq 'usage' ) {
 
+    my $usage = FS::cust_bill_pkg->usage_sql();
+    push @select, "($usage) AS _usage";
+    # there's already a method named 'usage'
+    $peritem[1] = '_usage';
+    $peritem_desc[1] = 'Usage charge';
+    $total[1] = "SUM($usage)";
+    $total_desc[1] .= ' usage charges';
   }
 
-} elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
+} elsif ( $cgi->param('istax') ) {
 
-  push @where, FS::tax_rate_location->location_sql(
-                 map { $_ => (scalar($cgi->param($_)) || '') }
-                   qw( district city county state locationtaxid )
-               );
+  @peritem = ( 'setup' ); # taxes only have setup
+  @peritem_desc = ( 'Tax charge' );
 
-}
+  push @where, 'cust_bill_pkg.pkgnum = 0';
 
-# unearned revenue mode
-if ( $cgi->param('unearned_now') =~ /^(\d+)$/ ) {
+  # tax location when using tax_rate_location
+  if ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
 
-  $unearned = $1;
-  $unearned_mode = $cgi->param('mode');
+    $join_pkg .= ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '.
+                 ' LEFT JOIN tax_rate_location USING ( taxratelocationnum )';
+    push @where, FS::tax_rate_location->location_sql(
+                   map { $_ => (scalar($cgi->param($_)) || '') }
+                     qw( district city county state locationtaxid )
+                 );
 
-  push @where, "cust_bill_pkg.sdate < $unearned",
-               "cust_bill_pkg.edate > $unearned",
-               "cust_bill_pkg.recur != 0",
-               "part_pkg.freq != '0'";
+    $total[1] = 'SUM(
+      COALESCE(cust_bill_pkg_tax_rate_location.amount, 
+               cust_bill_pkg.setup + cust_bill_pkg.recur)
+    )';
 
-  if ( !$cgi->param('include_monthly') ) {
-    push @where,
-               "part_pkg.freq != '1'",
-               "part_pkg.freq NOT LIKE '%h'",
-               "part_pkg.freq NOT LIKE '%d'",
-               "part_pkg.freq NOT LIKE '%w'";
-  }
+  } elsif ( $cgi->param('out') ) {
 
-  my $usage_sql = FS::cust_bill_pkg->usage_sql;
-  push @select, "($usage_sql) AS usage"; # we need this
-  my $paid_sql = 'GREATEST(' .
-    FS::cust_bill_pkg->paid_sql($unearned, '', setuprecur => 'recur') .
-    " - $usage_sql, 0)";
+    $join_pkg = '
+      LEFT JOIN cust_bill_pkg_tax_location USING (billpkgnum)
+    ';
+    push @where, 'cust_bill_pkg_tax_location.billpkgnum IS NULL';
 
-  push @select, "$paid_sql AS paid_no_usage"; # need this either way
+    # each billpkgnum should appear only once
+    $total[0] = 'COUNT(*)';
+    $total[1] = 'SUM(cust_bill_pkg.setup)';
 
-  if ( $unearned_mode eq 'paid' ) {
-    # then use the amount paid, minus usage charges
-    $unearned_base = $paid_sql;
-  }
-  else {
-    # use the amount billed, minus usage charges and credits
-    $unearned_base = "GREATEST( cust_bill_pkg.recur - ".
-      FS::cust_bill_pkg->credited_sql($unearned, '', setuprecur => 'recur') .
-      " - $usage_sql, 0)";
-      # include only rows that have some non-usage, non-credited portion
-  }
-  # whatever we're using as the base, only show rows where it's positive
-  push @where, "$unearned_base > 0";
-
-  my $period = "CAST(cust_bill_pkg.edate - cust_bill_pkg.sdate AS REAL)";
-  my $elapsed = "GREATEST( $unearned - cust_bill_pkg.sdate, 0 )";
-  my $remaining = "(1 - $elapsed/$period)";
-
-  $unearned_sql = "CAST( $unearned_base * $remaining AS DECIMAL(10,2) )";
-  push @select, "$unearned_sql AS unearned_revenue";
-
-  # last payment/credit date
-  my %t = (pay => 'cust_bill_pay', credit => 'cust_credit_bill');
-  foreach my $x (qw(pay credit)) {
-    my $table = $t{$x};
-    my $link = $table.'_pkg';
-    my $pkey = dbdef->table($table)->primary_key;
-    my $last_date_sql = "SELECT MAX(_date) 
-    FROM $table JOIN $link USING ($pkey)
-    WHERE $link.billpkgnum = cust_bill_pkg.billpkgnum 
-    AND $table._date <= $unearned";
-    push @select, "($last_date_sql) AS last_$x";
-  }
+  } else { # not locationtaxid or 'out'--the normal case
 
-}
+    $join_pkg = '
+      LEFT JOIN cust_bill_pkg_tax_location USING (billpkgnum)
+      JOIN cust_main_county           USING (taxnum)
+    ';
 
-if ( $cgi->param('itemdesc') ) {
-  if ( $cgi->param('itemdesc') eq 'Tax' ) {
-    push @where, "(itemdesc='Tax' OR itemdesc is null)";
-  } else {
-    push @where, 'itemdesc='. dbh->quote($cgi->param('itemdesc'));
+    # don't double-count the components of consolidated taxes
+    $total[0] = 'COUNT(DISTINCT cust_bill_pkg.billpkgnum)';
+    $total[1] = 'SUM(cust_bill_pkg_tax_location.amount)';
   }
-}
 
-if ( $cgi->param('report_group') =~ /^(=|!=) (.*)$/ && $cgi->param('istax') ) {
-  my ( $group_op, $group_value ) = ( $1, $2 );
-  if ( $group_op eq '=' ) {
-    #push @where, 'itemdesc LIKE '. dbh->quote($group_value.'%');
-    push @where, 'itemdesc = '. dbh->quote($group_value);
-  } elsif ( $group_op eq '!=' ) {
-    push @where, '( itemdesc != '. dbh->quote($group_value) .' OR itemdesc IS NULL )';
-  } else {
-    die "guru meditation #00de: group_op $group_op\n";
+  # taxclass
+  if ( $cgi->param('taxclassNULL') ) {
+    push @where, 'cust_main_county.taxclass IS NULL';
   }
-  
-}
 
-push @where, 'cust_bill_pkg.pkgnum != 0' if $cgi->param('nottax');
-push @where, 'cust_bill_pkg.pkgnum  = 0' if $cgi->param('istax');
-
-if ( $cgi->param('cust_tax') ) {
-  #false laziness -ish w/report_tax.cgi
-  my $cust_exempt;
-  if ( $cgi->param('taxname') ) {
-    my $q_taxname = dbh->quote($cgi->param('taxname'));
-    $cust_exempt =
-      "( tax = 'Y'
-         OR EXISTS ( SELECT 1 FROM cust_main_exemption
-                       WHERE cust_main_exemption.custnum = cust_main.custnum
-                         AND cust_main_exemption.taxname = $q_taxname )
-       )
-      ";
-  } else {
-    $cust_exempt = " tax = 'Y' ";
+  # taxname
+  if ( $cgi->param('taxnameNULL') ) {
+    push @where, 'cust_main_county.taxname IS NULL OR '.
+                 'cust_main_county.taxname = \'Tax\'';
+  } elsif ( $cgi->param('taxname') ) {
+    push @where, 'cust_main_county.taxname = '.
+                  dbh->quote($cgi->param('taxname'));
   }
 
-  push @where, $cust_exempt;
-}
-
-my $use_usage = $cgi->param('use_usage');
-
-my $count_query;
-if ( $cgi->param('pkg_tax') ) {
-
-  $count_query =
-    "SELECT COUNT(*),
-            SUM(
-                 ( CASE WHEN part_pkg.setuptax = 'Y'
-                        THEN cust_bill_pkg.setup
-                        ELSE 0
-                   END
-                 )
-                 +
-                 ( CASE WHEN part_pkg.recurtax = 'Y'
-                        THEN cust_bill_pkg.recur
-                        ELSE 0
-                   END
-                 )
-               )
-    ";
-
-  push @where, "(    ( part_pkg.setuptax = 'Y' AND cust_bill_pkg.setup > 0 )
-                  OR ( part_pkg.recurtax = 'Y' AND cust_bill_pkg.recur > 0 ) )",
-               "( tax != 'Y' OR tax IS NULL )";
-
-} elsif ( $cgi->param('taxable') ) {
-
-  my $setup_taxable = "(
-    CASE WHEN part_pkg.setuptax = 'Y'
-         THEN 0
-         ELSE cust_bill_pkg.setup
-    END
-  )";
-
-  my $recur_taxable = "(
-    CASE WHEN part_pkg.recurtax = 'Y'
-         THEN 0
-         ELSE cust_bill_pkg.recur
-    END
-  )";
-
-  my $exempt = "(
-    SELECT COALESCE( SUM(amount), 0 ) FROM cust_tax_exempt_pkg
-      WHERE cust_tax_exempt_pkg.billpkgnum = cust_bill_pkg.billpkgnum
-  )";
-
-  $count_query =
-    "SELECT COUNT(*), SUM( $setup_taxable + $recur_taxable - $exempt )";
-
-  push @where,
-    #not tax-exempt package (setup or recur)
-    "(
-          ( ( part_pkg.setuptax != 'Y' OR part_pkg.setuptax IS NULL )
-            AND cust_bill_pkg.setup > 0 )
-       OR
-          ( ( part_pkg.recurtax != 'Y' OR part_pkg.recurtax IS NULL )
-            AND cust_bill_pkg.recur > 0 )
-    )",
-    #not a tax_exempt customer
-    "( tax != 'Y' OR tax IS NULL )";
-    #not covered in full by a monthly tax exemption (texas tax)
-    "0 < ( $setup_taxable + $recur_taxable - $exempt )",
-
-} else {
-
-  if ( $use_usage ) {
-    $count_query = "SELECT COUNT(*), ";
-  } else {
-    $count_query = "SELECT COUNT(DISTINCT billpkgnum), ";
+  # specific taxnums
+  if ( $cgi->param('taxnum') ) {
+    my $taxnum_in = join(',', 
+      grep /^\d+$/, $cgi->param('taxnum')
+    );
+    push @where, "cust_main_county.taxnum IN ($taxnum_in)"
+      if $taxnum_in;
   }
 
-  if ( $unearned ) {
-    $count_query .= "SUM( $unearned_base ), SUM( $unearned_sql )";
-  } elsif ( $use_usage eq 'recurring' ) {
-    $count_query .= "SUM(cust_bill_pkg.setup + cust_bill_pkg.recur - usage)";
-  } elsif ( $use_usage eq 'usage' ) {
-    $count_query .= "SUM(usage)";
-  } elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ) {
-    $count_query .= "SUM( COALESCE(cust_bill_pkg_tax_rate_location.amount, cust_bill_pkg.setup + cust_bill_pkg.recur))";
-  } elsif ( $cgi->param('iscredit') eq 'rate') {
-    $count_query .= "SUM( cust_credit_bill_pkg.amount )";
-  } else {
-    $count_query .= "SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)";
+  # report group (itemdesc)
+  if ( $cgi->param('report_group') =~ /^(=|!=) (.*)$/ ) {
+    my ( $group_op, $group_value ) = ( $1, $2 );
+    if ( $group_op eq '=' ) {
+      #push @where, 'itemdesc LIKE '. dbh->quote($group_value.'%');
+      push @where, 'itemdesc = '. dbh->quote($group_value);
+    } elsif ( $group_op eq '!=' ) {
+      push @where, '( itemdesc != '. dbh->quote($group_value) .' OR itemdesc IS NULL )';
+    } else {
+      die "guru meditation #00de: group_op $group_op\n";
+    }
   }
 
-}
-
-$join_cust =  '        JOIN cust_bill USING ( invnum )
-                  LEFT JOIN cust_main USING ( custnum ) ';
-
-if ( $cgi->param('nottax') ) {
-
-  $join_pkg .=  ' LEFT JOIN cust_pkg USING ( pkgnum )
-                  LEFT JOIN part_pkg USING ( pkgpart )
-                  LEFT JOIN part_pkg AS override
-                    ON pkgpart_override = override.pkgpart ';
-  $join_pkg .= ' LEFT JOIN cust_location USING ( locationnum ) '
-    if $conf->exists('tax-pkg_address');
-
-} elsif ( $cgi->param('istax') ) {
-
-  #false laziness w/report_tax.cgi $taxfromwhere
-  if ( scalar( grep( /locationtaxid/, $cgi->param ) ) ||
-            $cgi->param('iscredit') eq 'rate') {
+  # itemdesc, for some reason
+  if ( $cgi->param('itemdesc') ) {
+    if ( $cgi->param('itemdesc') eq 'Tax' ) {
+      push @where, "(itemdesc='Tax' OR itemdesc is null)";
+    } else {
+      push @where, 'itemdesc='. dbh->quote($cgi->param('itemdesc'));
+    }
+  }
 
-    $join_pkg .=
-      ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '.
-      ' LEFT JOIN tax_rate_location USING ( taxratelocationnum ) ';
+} # nottax / istax
 
-  } elsif ( $conf->exists('tax-pkg_address') ) {
+# credit
+if ( $cgi->param('credit') ) {
 
-    $join_pkg .= ' LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
-                   LEFT JOIN cust_location              USING ( locationnum ) ';
+  my $credit_sub;
 
-    #quelle kludge, somewhat false laziness w/report_tax.cgi
-    s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g for @where;
-  }
+  if ( $cgi->param('istax') ) {
+    # then we need to group/join by billpkgtaxlocationnum, to get only the 
+    # relevant part of partial taxes
+    my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount,
+      reason.reason as reason_text, access_user.username AS username_text,
+      billpkgtaxlocationnum, billpkgnum
+    FROM cust_credit_bill_pkg
+      JOIN cust_credit_bill USING (creditbillnum)
+      JOIN cust_credit USING (crednum)
+      LEFT JOIN reason USING (reasonnum)
+      LEFT JOIN access_user USING (usernum)
+    GROUP BY billpkgnum, billpkgtaxlocationnum, reason.reason, 
+      access_user.username";
+
+    if ( $cgi->param('out') ) {
+
+      # find credits that are applied to the line items, but not to 
+      # a cust_bill_pkg_tax_location link
+      $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit
+        USING (billpkgnum)";
+      push @where, 'item_credit.billpkgtaxlocationnum IS NULL';
 
-  if ( $cgi->param('iscredit') ) {
-    $join_pkg .= ' JOIN cust_credit_bill_pkg USING ( billpkgnum';
-    if ( $cgi->param('iscredit') eq 'rate' ) {
-      $join_pkg .= ', billpkgtaxratelocationnum )';
-    } elsif ( $conf->exists('tax-pkg_address') ) {
-      $join_pkg .= ', billpkgtaxlocationnum )';
-      push @where, "billpkgtaxratelocationnum IS NULL";
     } else {
-      $join_pkg .= ' )';
-      push @where, "billpkgtaxratelocationnum IS NULL";
-    }
-  }
 
-} else { 
+      # find credits that are applied to the CBPTL links that are 
+      # considered "interesting" by the report criteria
+      $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit
+        USING (billpkgtaxlocationnum)";
 
-  #die?
-  warn "neiether nottax nor istax parameters specified";
-  #same as before?
-  $join_pkg =  ' LEFT JOIN cust_pkg USING ( pkgnum )
-                 LEFT JOIN part_pkg USING ( pkgpart ) ';
+    }
 
-}
+  } else {
+    # then only group by billpkgnum
+    my $credit_sub = "SELECT SUM(cust_credit_bill_pkg.amount) AS credit_amount,
+      reason.reason as reason_text, access_user.username AS username_text,
+      billpkgnum
+    FROM cust_credit_bill_pkg
+      JOIN cust_credit_bill USING (creditbillnum)
+      JOIN cust_credit USING (crednum)
+      LEFT JOIN reason USING (reasonnum)
+      LEFT JOIN access_user USING (usernum)
+    GROUP BY billpkgnum, reason.reason, access_user.username";
+    $join_pkg .= " LEFT JOIN ($credit_sub) AS item_credit USING (billpkgnum)";
+  }
 
-my $where = ' WHERE '. join(' AND ', @where);
-
-if ($use_usage) {
-  $count_query .=
-    " FROM (SELECT cust_bill_pkg.setup, cust_bill_pkg.recur, 
-             ( SELECT COALESCE( SUM(amount), 0 ) FROM cust_bill_pkg_detail
-               WHERE cust_bill_pkg.billpkgnum = cust_bill_pkg_detail.billpkgnum
-             ) AS usage FROM cust_bill_pkg  $join_cust $join_pkg $where
-           ) AS countquery";
-} else {
-  $count_query .= " FROM cust_bill_pkg $join_cust $join_pkg $where";
-}
+  push @where,    'item_credit.billpkgnum IS NOT NULL';
+  push @select,   'item_credit.credit_amount',
+                  'item_credit.username_text',
+                  'item_credit.reason_text';
+  push @peritem,  'credit_amount', 'username_text', 'reason_text';
+  push @peritem_desc, 'Credited', 'By', 'Reason';
+  push @total,    'SUM(credit_amount)';
+  push @total_desc, "$money_char%.2f credited";
+} # if credit
 
-push @select, 'part_pkg.pkg',
-              'part_pkg.freq',
-  unless $cgi->param('istax');
+push @select, 'cust_main.custnum', FS::UI::Web::cust_sql_fields();
 
-push @select, 'cust_main.custnum',
-              FS::UI::Web::cust_sql_fields();
+my $where = join(' AND ', @where);
+$where &&= "WHERE $where";
 
 my $query = {
   'table'     => 'cust_bill_pkg',
@@ -624,25 +557,31 @@ my $query = {
   'hashref'   => {},
   'select'    => join(",\n", @select ),
   'extra_sql' => $where,
-  'order_by'  => 'ORDER BY cust_bill._date, billpkgnum',
+  'order_by'  => 'ORDER BY cust_bill._date, cust_bill_pkg.billpkgnum',
 };
 
-my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
-my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
+my $count_query =
+  'SELECT ' . join(',', @total) .
+  " FROM cust_bill_pkg $join_cust $join_pkg
+  $where";
 
-my $conf = new FS::Conf;
-my $money_char = $conf->config('money_char') || '$';
+shift @total_desc; #the first one is implicit
 
-my $owed_sub = sub {
-  $money_char . shift->get('owed') # owed_recur is not correct here
-};
-my $payment_date_sub = sub {
-  #my $cust_bill_pkg = shift;
-  my @cust_pay = sort { $a->_date <=> $b->_date }
-                      map $_->cust_bill_pay->cust_pay,
-                          shift->cust_bill_pay_pkg('recur') #recur :/
-    or return '';
-  time2str('%b %d %Y', $cust_pay[-1]->_date );
-};
+@peritem_desc = map {emt($_)} @peritem_desc;
+my @peritem_sub = map {
+  my $field = $_;
+  if ($field =~ /_text$/) { # kludge for credit reason/username fields
+    sub {$_[0]->get($field)};
+  } else {
+    sub { sprintf($money_char.'%.2f', $_[0]->get($field)) }
+  }
+} @peritem;
+my @peritem_null = map { '' } @peritem; # placeholders
+my $peritem_align = 'r' x scalar(@peritem);
+
+my $ilink = [ "${p}view/cust_bill.cgi?", 'invnum' ];
+my $clink = [ "${p}view/cust_main.cgi?", 'custnum' ];
 
+warn "\n\nQUERY:\n".Dumper($query)."\n\nCOUNT_QUERY:\n$count_query\n\n"
+  if $cgi->param('debug');
 </%init>
index 3cb434c..77b4860 100644 (file)
@@ -146,6 +146,16 @@ if ( @status_where ) {
     ') IN (' . join(',', @status_where) .')';
 }
 
+my @refnum;
+foreach my $refnum ($cgi->param('refnum')) {
+  if ( $refnum =~ /^\d+$/ ) {
+    push @refnum, $refnum;
+  }
+}
+if ( @refnum ) {
+  push @where, 'cust_main.refnum IN ('.join(',', @refnum).')';
+}
+
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   push @where, "cust_main.agentnum = $1";
 }
index c317dc3..08800d4 100644 (file)
@@ -4,8 +4,8 @@
                  'query'       => $sql_query,
                  'count_query' => $count_sql,
                  'header'      => [ 'Zip code', 'Customers', ],
-                 #'fields'      => [ 'zip', 'num_cust', ],
-                 #'links'       => [ '', sub { 'somewhere'; }  ],
+                 'fields'      => [ 0, 1 ],
+                 'links'       => [ '', $link  ],
              )
 %>
 <%init>
@@ -63,48 +63,36 @@ sub strip_plus4 {
   END";
 }
 
-my( $zip, $czip);
-if ( $cgi->param('column') eq 'ship_zip' ) {
-
-  my $casewhen_noship =
-    "CASE WHEN ( ship_last IS NULL OR ship_last = '' ) THEN ";
-
-  $czip = "$casewhen_noship zip ELSE ship_zip END";
-
-  if ( $cgi->param('ignore_plus4') ) {
-    $zip = $casewhen_noship. strip_plus4('zip').
-                   " ELSE ". strip_plus4('ship_zip'). ' END';
-
-  } else {
-    $zip = $casewhen_noship. fieldorempty('zip').
-                   " ELSE ". fieldorempty('ship_zip'). ' END';
-  }
+$cgi->param('column') =~ /^(bill|ship)$/;
+my $location = $1 || 'bill';
+$location .= '_locationnum';
 
+my $zip;
+if ( $cgi->param('ignore_plus4') ) {
+  $zip = strip_plus4('cust_location.zip');
 } else {
-
-  $czip = 'zip';
-
-  if ( $cgi->param('ignore_plus4') ) {
-    $zip = strip_plus4('zip');
-  } else {
-    $zip = fieldorempty('zip');
-  }
-
+  $zip = fieldorempty('cust_location.zip');
 }
 
 # construct the queries and send 'em off
 
+my $join = "JOIN cust_location ON (cust_main.$location = cust_location.locationnum)";
+
 my $sql_query = 
   "SELECT $zip AS zipcode,
           COUNT(*) AS num_cust
      FROM cust_main
+     $join
      $where
      GROUP BY zipcode
-     ORDER BY num_cust DESC
+     ORDER BY num_cust DESC, $zip ASC
   ";
 
-my $count_sql = "select count(distinct $czip) from cust_main $where";
+my $count_sql = 
+  "SELECT COUNT(DISTINCT cust_location.zip)
+    FROM cust_main $join $where";
 
-# XXX should link...
+my $link = [ $p.'search/cust_main.html?zip=', 
+             sub { $_[0]->[0] } ];
 
 </%init>
index 859ef04..7c3ad33 100755 (executable)
       <TR>
         <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('#') |h %></TH>
         <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('Status') |h %></TH>
-        <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('(bill) name') |h %></TH>
-        <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('company') |h %></TH>
-
-%if ( defined dbdef->table('cust_main')->column('ship_last') ) {
-      <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('(service) name') |h %></TH>
-      <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('company') |h %></TH>
-%}
+        <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('Name') |h %></TH>
+        <TH CLASS="grid" BGCOLOR="#cccccc"><% mt('Company') |h %></TH>
 
 %foreach my $addl_header ( @addl_headers ) {
     <TH CLASS="grid" BGCOLOR="#cccccc"><% $addl_header %></TH>
         <% $pcompany %>
       </TD>
 
-%    if ( defined dbdef->table('cust_main')->column('ship_last') ) {
-%      my($ship_last,$ship_first,$ship_company)=(
-%        $cust_main->ship_last || $cust_main->getfield('last'),
-%        $cust_main->ship_last ? $cust_main->ship_first : $cust_main->first,
-%        $cust_main->ship_last ? $cust_main->ship_company : $cust_main->company,
-%      );
-%      my $pship_company = $ship_company
-%        ? qq!<A HREF="$view"><FONT SIZE=-1>$ship_company</FONT></A>!
-%        : '<FONT SIZE=-1>&nbsp;</FONT>';
-%      
-
-      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>" ROWSPAN=<% $rowspan %>>
-        <A HREF="<% $view %>"><FONT SIZE=-1><% "$ship_last, $ship_first" %></FONT></A>
-      </TD>
-      <TD CLASS="grid" BGCOLOR="<% $bgcolor %>" ROWSPAN=<% $rowspan %>>
-        <% $pship_company %></A>
-      </TD>
-% }
-%
 %    foreach my $addl_col ( @addl_cols ) { 
 % if ( $addl_col eq 'tickets' ) { 
 % if ( @custom_priorities ) { 
@@ -492,9 +468,10 @@ if ( $cgi->param('browse')
   if ( $cgi->param('search_cust') ) {
     $sortby = \*company_sort;
     $orderby = "ORDER BY LOWER(company || ' ' || last || ' ' || first )";
-    push @cust_main, smart_search( 'search' => scalar($cgi->param('search_cust')),
-                                   'no_fuzzy_on_exact' => 1, #pref?
-                                 );
+    push @cust_main, smart_search(
+      'search'            => scalar($cgi->param('search_cust')),
+      'no_fuzzy_on_exact' => ! $curuser->option('enable_fuzzy_on_exact'),
+    );
   }
 
   @cust_main = grep { $_->ncancelled_pkgs || ! $_->all_pkgs } @cust_main
index e164b98..fa79b4d 100755 (executable)
@@ -41,7 +41,7 @@ my %search_hash = ();
 
 #scalars
 my @scalars = qw (
-  agentnum status address paydate_year paydate_month invoice_terms
+  agentnum status address zip paydate_year paydate_month invoice_terms
   no_censustract with_geocode custbatch usernum
   cancelled_pkgs
   cust_fields flattened_pkgs
@@ -61,7 +61,7 @@ for my $param (qw( classnum refnum payby tagnum )) {
 # parse dates
 ###
 
-foreach my $field (qw( signupdate birthdate spouse_birthdate )) {
+foreach my $field (qw( signupdate birthdate spouse_birthdate anniversary_date )) {
 
   my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi, $field);
 
index 8b73508..2afce0c 100755 (executable)
@@ -5,7 +5,6 @@
                 'name_verb'     => 'pending',
                 'disable_link'  => 1,
                 'disable_by'    => 1, #add otaker to cust_pay_pending?
-                'html_init'     => include('/elements/init_overlib.html'),
                 'addl_header'   => [ 'Time', 'Payment Status', ],
                 'addl_fields'   => [ sub { time2str('%r', shift->_date ) },
                                      $status_sub,
index 3a5155a..1b767f8 100644 (file)
@@ -103,7 +103,7 @@ my $join = "
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('View customer tax exemptions');
 
-my @where = ();
+my @where = ("exempt_monthly = 'Y'");
 
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 if ( $beginning || $ending ) {
@@ -121,6 +121,7 @@ if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
 }
 
 if ( $cgi->param('out') ) {
+  # wtf? how would you ever get exemptions on a non-taxable package location?
 
   push @where, "
     0 = (
@@ -151,6 +152,11 @@ if ( $cgi->param('out') ) {
   push @where, 'taxclass = '. dbh->quote( $cgi->param('taxclass') )
     if $cgi->param('taxclass');
 
+} elsif ( $cgi->param('taxnum') ) {
+
+  my $taxnum_in = join(',', grep /^\d+$/, $cgi->param('taxnum') );
+  push @where, "taxnum IN ($taxnum_in)" if $taxnum_in;
+
 }
 
 my $where = scalar(@where) ? 'WHERE '.join(' AND ', @where) : '';
index 739e65b..1dcc37a 100644 (file)
@@ -33,6 +33,7 @@ Download batch in format <SELECT NAME="format">
               'action'    => "${p}misc/upload-batch.cgi",
               'num_files' => 1,
               'fields'    => [ 'batchnum', 'format', 'gatewaynum' ],
+              'url'       => $cgi->self_url,
               'message'   => 'Batch results uploaded.',
 ) %>
 Upload results<BR></TR>
@@ -87,7 +88,7 @@ Batch is <% $statustext{$status} %><BR>
 
 <%def .select_gateway>
 % if ( $show_gateways ) {
- or from gateway
+ or for gateway
 <& /elements/select-table.html,
   empty_label => ' ',
   field     => 'gatewaynum',
index 9eb1b66..90230e6 100644 (file)
 %     $csv->combine(@$row); #or die $csv->status;
 %   }
 %
-%      
 <% $csv->string %>\
 %
 % }
+% 
+% if ( $opt{'footer'} and !$opt{'no_csv_header'} ) {
+%   my @footer;
+%   foreach my $item (@{ $opt{'footer'} }) {
+%     if ( ref($item) eq 'CODE' ) {
+%       $item = &{$item}();
+%     }
+%     push @footer, $item;
+%   }
+%   $csv->combine(@footer);
+<% $csv->string %>\
+% }
 <%init>
 
 my %args = @_;
index 53167c2..d7e8128 100644 (file)
 %                 and !$opt{'disable_download'}
 %                 and $type ne 'html-print' ) { 
 
-              <TD ALIGN="right">
+              <TD ALIGN="right" CLASS="noprint">
 
-                Download full results<BR>
+                <% $opt{'download_label'} || 'Download full results' %><BR>
 
 %               $cgi->param('_type', "$xlsname.xls" ); 
                 as <A HREF="<% "$self_url?". $cgi->query_string %>">Excel spreadsheet</A><BR>
 %                       map {
 %                             if ( ref($_) eq 'CODE' ) {
 %                               &{$_}($row);
+%                             } elsif ( ref($row) eq 'ARRAY' and 
+%                                       $_ =~ /^\d+$/ ) {
+%                             # for the 'straight SQL' case: specify fields
+%                             # by position
+%                               $row->[$_];
 %                             } else {
 %                               $row->$_();
 %                             }
 %
 %                     ) {
 %
-%                       my $class = ( $field =~ /^<TABLE/i ) ? 'inv' : 'grid';
+%#                       my $class = ( $field =~ /^<TABLE/i ) ? 'inv' : 'grid';
+%                       my $class = 'grid';
 %
 %                       my $align = $aligns ? shift @$aligns : '';
 %                       $align = " ALIGN=$align" if $align;
index 09dbe46..94d88b0 100644 (file)
@@ -55,6 +55,10 @@ my $writer = sub {
   # Wrapper for $worksheet->write.
   # Do any massaging of the value/format here.
   my ($r, $c, $value, $format) = @_;
+  # convert HTML entities
+  # both Spreadsheet::WriteExcel and Excel::Writer::XLSX accept UTF-8 strings
+  $value = decode_entities($value);
+
   if ( $value =~ /^\Q$money_char\E(-?\d+\.?\d*)$/ ) {
     # Currency: strip the symbol, clone the requested format,
     # and format it for currency
@@ -130,6 +134,17 @@ foreach my $row ( @$rows ) {
 
 }
 
+if ( $opt{'footer'} ) {
+  $r++;
+  $c = 0;
+  foreach my $item (@{ $opt{'footer'} }) {
+    if ( ref($item) eq 'CODE' ) {
+      $item = &{$item}();
+    }
+    $writer->( $r, $c++, $item, $header_format );
+  }
+}
+
 $workbook->close();# or die "Error creating .xls file: $!";
 
 http_header('Content-Length' => length($data) );
index 9bc66b6..eca68a2 100644 (file)
@@ -162,7 +162,11 @@ Example:
     # Excel-specific listref of ( hashrefs or coderefs )
     # each hashref: http://search.cpan.org/dist/Spreadsheet-WriteExcel/lib/Spreadsheet/WriteExcel.pm#Format_methods_and_Format_properties
     'xls_format' => => [],
-    
+   
+
+    # miscellany
+   'download_label' => 'Download this report',
+                        # defaults to 'Download full results' 
   &>
 
 </%doc>
diff --git a/httemplate/search/quotation.html b/httemplate/search/quotation.html
new file mode 100755 (executable)
index 0000000..259c85c
--- /dev/null
@@ -0,0 +1,268 @@
+<& elements/search.html,
+                 'title'       => emt('Quotation Search Results'),
+                 'html_init'   => $html_init,
+                 'menubar'     => $menubar,
+                 'name'        => 'quotations',
+                 'query'       => $sql_query,
+                 'count_query' => $count_query,
+                 'count_addl'  => $count_addl,
+                 'redirect'    => $link,
+                 'header'      => [ emt('Quotation #'),
+                                    emt('Setup'),
+                                    emt('Recurring'),
+                                    emt('Date'),
+                                    emt('Prospect'),
+                                    emt('Customer'),
+                                  ],
+                 'fields'      => [
+                   'quotationnum',
+                   sub { $money_char. shift->total_setup },
+                   sub { $money_char. shift->total_recur },
+                   sub { time2str('%b %d %Y', shift->_date ) },
+                   sub { my $prospect_main = shift->prospect_main;
+                         $prospect_main ? $prospect_main->name : '';
+                       },
+                   sub { my $cust_main = shift->cust_main;
+                         $cust_main ? $cust_main->name : '';
+                       },
+                   #\&FS::UI::Web::cust_fields,
+                 ],
+                 'sort_fields' => [
+                   'quotationnum',
+                   '', #FS::quotation->total_setup_sql,
+                   '', #FS::quotation->total_recur_sql,
+                   '_date',
+                   '',
+                   '',
+                 ],
+                 'align' => 'rrrrll', #.FS::UI::Web::cust_aligns(),
+                 'links' => [
+                   $link,
+                   $link,
+                   $link,
+                   $link,
+                   $prospect_link,
+                   $cust_link,
+                   #( map { $_ ne 'Cust. Status' ? $clink : '' }
+                   #      FS::UI::Web::cust_header()
+                   #),
+                 ],
+#                 'color' => [ 
+#                              '',
+#                              '',
+#                              '',
+#                              '',
+#                              '',
+#                              FS::UI::Web::cust_colors(),
+#                            ],
+#                 'style' => [ 
+#                              '',
+#                              '',
+#                              '',
+#                              '',
+#                              '',
+#                              FS::UI::Web::cust_styles(),
+#                            ],
+&>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('List quotations');
+
+my $join_prospect_main = 'LEFT JOIN prospect_main USING ( prospectnum )';
+my $join_cust_main = 'LEFT JOIN cust_main ON ( quotation.custnum = cust_main.custnum )';
+
+#here is the agent virtualization
+my $agentnums_sql = ' (    '. $curuser->agentnums_sql( table=>'prospect_main' ).
+                    '   OR '. $curuser->agentnums_sql( table=>'cust_main' ).
+                    ' )    ';
+
+my( $count_query, $sql_query );
+my $count_addl = '';
+my %search;
+
+#if ( $cgi->param('quotationnum') =~ /^\s*(FS-)?(\d+)\s*$/ ) {
+#
+#  my $where = "WHERE quotationnum = $2 AND $agentnums_sql";
+#  
+#  $count_query = "SELECT COUNT(*) FROM quotation $join_prospect_main $join_cust_main $where";
+#
+#  $sql_query = {
+#    'table'     => 'quotation',
+#    'addl_from' => "$join_prospect_main $join_cust_main",
+#    'hashref'   => {},
+#    'extra_sql' => $where,
+#  };
+#
+#} else {
+
+  #some false laziness w/cust_bill::re_X
+  my $orderby = 'ORDER BY quotation._date';
+
+  if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
+    $search{'agentnum'} = $1;
+  }
+
+#  if ( $cgi->param('refnum') =~ /^(\d+)$/ ) {
+#    $search{'refnum'} = $1;
+#  }
+
+  if ( $cgi->param('prospectnum') =~ /^(\d+)$/ ) {
+    $search{'prospectnum'} = $1;
+  }
+
+  if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+    $search{'custnum'} = $1;
+  }
+
+  # begin/end/beginning/ending
+  my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi, '');
+  $search{'_date'} = [ $beginning, $ending ]
+    unless $beginning == 0 && $ending == 4294967295;
+
+  if ( $cgi->param('quotationnum_min') =~ /^\s*(\d+)\s*$/ ) {
+    $search{'quotationnum_min'} = $1;
+  }
+  if ( $cgi->param('quotationnum_max') =~ /^\s*(\d+)\s*$/ ) {
+    $search{'quotationnum_max'} = $1;
+  }
+
+  #amounts
+  $search{$_} = [ FS::UI::Web::parse_lt_gt($cgi, $_) ]
+    foreach qw( total_setup total_recur );
+
+#  my($query) = $cgi->keywords;
+#  if ( $query =~ /^(OPEN(\d*)_)?(invnum|date|custnum)$/ ) {
+#    $search{'open'} = 1 if $1;
+#    ($search{'days'}, my $field) = ($2, $3);
+#    $field = "_date" if $field eq 'date';
+#    $orderby = "ORDER BY cust_bill.$field";
+#  }
+
+#  if ( $cgi->param('newest_percust') ) {
+#    $search{'newest_percust'} = 1;
+#    $count_query = "SELECT COUNT(DISTINCT cust_bill.custnum), 'N/A', 'N/A'";
+#  }
+
+  my $extra_sql = ' WHERE '. FS::quotation->search_sql_where( \%search );
+
+  unless ( $count_query ) {
+    $count_query = 'SELECT COUNT(*)';
+  }
+  $count_query .=  " FROM quotation $join_prospect_main $join_cust_main $extra_sql";
+
+  $sql_query = {
+    'table'     => 'quotation',
+    'addl_from' => "$join_prospect_main $join_cust_main",
+    'hashref'   => {},
+    'select'    => join(', ',
+                     'quotation.*',
+                     #( map "cust_main.$_", qw(custnum last first company) ),
+                     'prospect_main.prospectnum as prospect_main_prospectnum',
+                     'cust_main.custnum as cust_main_custnum',
+                     #FS::UI::Web::cust_sql_fields(),
+                   ),
+    'extra_sql' => $extra_sql,
+    'order_by'  => $orderby,
+  };
+
+#}
+
+my $link  = [ "${p}view/quotation.html?", 'quotationnum', ];
+my $prospect_link = sub {
+  my $quotation = shift;
+  $quotation->prospect_main_prospectnum
+    ? [ "${p}view/prospect_main.html?", 'prospectnum' ]
+    : '';
+};
+
+my $cust_link = sub {
+  my $quotation = shift;
+  $quotation->cust_main_custnum
+    ? [ "${p}view/cust_main.cgi?", 'custnum' ]
+    : '';
+};
+
+my $conf = new FS::Conf;
+my $money_char = $conf->config('money_char') || '$';
+
+my $html_init = join("\n", map {
+ ( my $action = $_ ) =~ s/_$//;
+ include('/elements/progress-init.html',
+           $_.'form',
+           [ keys %search ],
+           "../misc/${_}invoices.cgi",
+           { 'message' => "Invoices re-${action}ed" }, #would be nice to show the number of them, but...
+           $_, #key
+        ),
+ qq!<FORM NAME="${_}form">!,
+ ( map { my $f = $_;
+         my @values = ref($search{$f}) ? @{ $search{$f} } : $search{$f};
+         map qq!<INPUT TYPE="hidden" NAME="$f" VALUE="$_">!, @values;
+       }
+       keys %search
+ ),
+ qq!</FORM>!
+} qw( print_ email_ fax_ ftp_ spool_ ) ). 
+
+'<SCRIPT TYPE="text/javascript">
+
+function confirm_print_process() {
+  if ( ! confirm('.js_mt("Are you sure you want to reprint these invoices?").') ) {
+    return;
+  }
+  print_process();
+}
+function confirm_email_process() {
+  if ( ! confirm('.js_mt("Are you sure you want to re-email these invoices?").') ) {
+    return;
+  }
+  email_process();
+}
+function confirm_fax_process() {
+  if ( ! confirm('.js_mt("Are you sure you want to re-fax these invoices?").') ) {
+    return;
+  }
+  fax_process();
+}
+function confirm_ftp_process() {
+  if ( ! confirm('.js_mt("Are you sure you want to re-FTP these invoices?").') ) {
+    return;
+  }
+  ftp_process();
+}
+function confirm_spool_process() {
+  if ( ! confirm('.js_mt("Are you sure you want to re-spool these invoices?").') ) {
+    return;
+  }
+  spool_process();
+}
+
+</SCRIPT>';
+
+my $menubar = [];
+
+#if ( $curuser->access_right('Resend quotations') ) {
+#
+#  push @$menubar, emt('Print these invoices') =>
+#                    "javascript:confirm_print_process()",
+#                  emt('Email these invoices') =>
+#                    "javascript:confirm_email_process()";
+#
+#  push @$menubar, emt('Fax these invoices') =>
+#                    "javascript:confirm_fax_process()"
+#    if $conf->exists('hylafax');
+#
+#  push @$menubar, emt('FTP these invoices') =>
+#                    "javascript:confirm_ftp_process()"
+#    if $conf->exists('cust_bill-ftpformat');
+#
+#  push @$menubar, emt('Spool these invoices') =>
+#                    "javascript:confirm_spool_process()"
+#    if $conf->exists('cust_bill-spoolformat');
+#
+#}
+
+</%init>
index c9d97c5..f593a94 100755 (executable)
                )
     %>
 
+%   # not tr-select-state, we only want to choose from among those that 
+%   # have customers
+    <& /elements/tr-select-table.html,
+        'label'         => 'State',
+        'field'         => 'state',
+        'table'         => 'cust_location',
+        'name_col'      => 'state',
+        'value_col'     => 'state',
+        'disable_empty' => 1,
+        'records'       => \@states,
+    &>
+
     <% include( '/elements/tr-select-pkg_class.html',
                    'multiple'       => 1,
                    'empty_label' => '(empty class)',
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('List packages');
 
+my @states = qsearch({
+  'table'   => 'cust_location',
+  'select'  => 'DISTINCT(state)',
+  'hashref' => { 'country' => 'US' }, # 477 report isn't relevant elsewhere
+});
+
 </%init>
index e3418a7..0e1693b 100644 (file)
       <SELECT NAME="freesidestatus">
         <OPTION VALUE="">(all)</OPTION>
         <OPTION VALUE="NULL">unprocessed</OPTION>
+%# <OPTION VALUE="processing-tiered">processing</OPTION>
         <OPTION VALUE="rated">prerated
-        <OPTION VALUE="done">processed</OPTION>
-        <OPTION VALUE="failed">skipped</OPTION>
+        <OPTION VALUE="no-charge">processed (included)</OPTION>
+        <OPTION VALUE="done">processed (billed)</OPTION>
+        <OPTION VALUE="skipped">skipped</OPTION>
+        <OPTION VALUE="failed">failed</OPTION>
       </SELECT>
     </TD>
   </TR>
index ff2caa1..b4716d4 100644 (file)
   'disable_empty' => 1,
 &>
 
+<& /elements/tr-select-part_referral.html,
+  'multiple'      => 1,
+  'disable_empty' => 1,
+&>
+
 <& /elements/tr-select-pkg_class.html,
   'pre_options' => [ '' => 'all', '0' => '(empty class)' ],
   'disable_empty' => 1,
index 00cb9ed..8bad332 100644 (file)
@@ -8,8 +8,8 @@
         <TD ALIGN="right">Billing or service zip</TD>
         <TD>
           <SELECT NAME="column">
-            <OPTION VALUE="zip">Billing zip
-            <OPTION VALUE="ship_zip">Service zip
+            <OPTION VALUE="bill">Billing zip
+            <OPTION VALUE="ship">Service zip
           </SELECT>
         </TD>
       </TR>
index 39cf695..3e7181d 100755 (executable)
     <& /elements/tr-select-part_referral.html,
                   'label'        => emt('Advertising Source'),
                   'multiple'     => 1,
-                  'all_selected' => 1,
+                  #no, causes customers with disabled ones to disappear
+                  #'all_selected' => 1,
     &>
 
     <TR>
       <TD ALIGN="right" VALIGN="center"><% mt('Address') |h %></TD>
       <TD><INPUT TYPE="text" NAME="address" SIZE=54></TD>
     </TR>
+    
+    <TR>
+      <TD ALIGN="right" VALIGN="center"><% mt('Zip') |h %></TD>
+      <TD><INPUT TYPE="text" NAME="zip" SIZE=12></TD>
+    </TR>
 
     <TR>
         <TD ALIGN="right" VALIGN="center"><% mt('Signup date') |h %></TD>
       </TR>
 %   }
 
+%    if ( $conf->exists('cust_main-enable_anniversary_date') ) {
+      <TR>
+          <TD ALIGN="right" VALIGN="center"><% mt('Anniversary Date') |h %></TD>
+          <TD>
+          <TABLE>
+              <& /elements/tr-input-beginning_ending.html,
+                        prefix   => 'anniversary_date',
+                        layout   => 'horiz',
+              &>
+          </TABLE>
+          </TD>
+      </TR>
+%   }
+
     <& /elements/tr-select-cust_tag.html,
                   'cgi'                 => $cgi,
                   'is_report'    => 1,
diff --git a/httemplate/search/report_quotation.html b/httemplate/search/report_quotation.html
new file mode 100644 (file)
index 0000000..1be904d
--- /dev/null
@@ -0,0 +1,75 @@
+<& /elements/header.html, mt($title, @title_arg) &>
+
+<FORM ACTION="quotation.html" METHOD="GET">
+<INPUT TYPE="hidden" NAME="magic" VALUE="_date">
+<INPUT TYPE="hidden" NAME="prospectnum" VALUE="<% $prospectnum %>">
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0
+
+% unless ( $custnum ) {
+  <& /elements/tr-select-agent.html,
+                 'curr_value'    => scalar( $cgi->param('agentnum') ),
+                 'label'         => emt('Quotations for agent: '),
+                 'disable_empty' => 0,
+  &>
+% }
+
+  <& /elements/tr-input-beginning_ending.html &>
+
+  <& /elements/tr-input-lessthan_greaterthan.html,
+                label   => emt('Setup'),
+                field   => 'total_setup',
+  &>
+
+  <& /elements/tr-input-lessthan_greaterthan.html,
+                label   => emt('Recurring'),
+                field   => 'total_recur',
+  &>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<& /elements/footer.html &>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('List quotations');
+
+my $conf = new FS::Conf;
+
+my $title = 'Quotation Report';
+#false laziness w/report_cust_pkg.html
+my @title_arg = ();
+
+my $prospectnum = '';
+my $prospect_main = '';
+if ( $cgi->param('prospectnum') =~ /^(\d+)$/ ) {
+  $prospectnum = $1;
+  $prospect_main = qsearchs({
+    'table'     => 'prospect_main', 
+    'hashref'   => { 'prospectnum' => $prospectnum },
+    'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+  }) or die "unknown prospectnum $prospectnum";
+  $title .= ': [_1]';
+  push @title_arg, $prospect_main->name;
+}
+
+my $custnum = '';
+my $cust_main = '';
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+  $custnum = $1;
+  $cust_main = qsearchs({
+    'table'     => 'cust_main', 
+    'hashref'   => { 'custnum' => $custnum },
+    'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+  }) or die "unknown custnum $custnum";
+  $title .= ': [_1]';
+  push @title_arg, $cust_main->name;
+}
+
+</%init>
index f0d7a42..a4ceaa6 100644 (file)
@@ -59,7 +59,6 @@ if ( @pkgparts ) {
 }
 
 # get a list of TimeValue-type custom fields
-RT::Init();
 my $CurrentUser = RT::CurrentUser->new();
 $CurrentUser->LoadByName($FS::CurrentUser::CurrentUser->username);
 die "RT not configured" unless $CurrentUser->id;
diff --git a/httemplate/search/report_sqlradius_usage.html b/httemplate/search/report_sqlradius_usage.html
new file mode 100644 (file)
index 0000000..01215e8
--- /dev/null
@@ -0,0 +1,40 @@
+<& /elements/header.html, mt($title) &>
+
+<FORM ACTION="sqlradius_usage.html" METHOD="GET">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0
+
+<& /elements/tr-select-agent.html,
+  'empty_label'   => 'all',
+&>
+
+% my @exporttypes = map { "'$_'" } qw(sqlradius broadband_sqlradius);
+<& /elements/tr-select-table.html,
+  'label'         => 'Export',
+  'table'         => 'part_export',
+  'name_col'      => 'label',
+  'hashref'       => {},
+  'extra_sql'     => ' WHERE exporttype IN('.join(',', @exporttypes).')',
+  'disable_empty' => 1,
+  'order_by'      => 'ORDER BY exportnum',
+&>
+
+<& /elements/tr-input-beginning_ending.html &>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="<% mt('Get Report') |h %>">
+
+</FORM>
+
+<& /elements/footer.html &>
+<%init>
+
+die "access denied"
+  unless $FS::CurrentUser::CurrentUser->access_right('Usage: RADIUS sessions');
+  # yes?
+
+my $title = 'Data Usage Report';
+
+</%init>
index 2786f57..42a52d1 100755 (executable)
@@ -60,9 +60,9 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
 %   my $link = '';
 %   if ( $region->{'label'} eq $out ) {
 %     $link = ';out=1';
-%   } else {
-%     $link = ';'. $region->{'url_param'}
-%       if $region->{'url_param'};
+%   } elsif ( $region->{'taxnums'} ) {
+%     # might be nicer to specify this as country:state:city
+%     $link = ';'.join(';', map { "taxnum=$_" } @{ $region->{'taxnums'} });
 %   }
 %
 %   if ( $bgcolor eq $bgcolor1 ) {
@@ -71,15 +71,12 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
 %     $bgcolor = $bgcolor1;
 %   }
 %
-%   #my $diff = 0;
 %   my $hicolor = $bgcolor;
 %   unless ( $cgi->param('show_taxclasses') ) {
 %     my $diff = abs(   sprintf( '%.2f', $region->{'owed'} )
 %                     - sprintf( '%.2f', $region->{'tax'}  )
 %                   );
 %     if ( $diff > 0.02 ) {
-%     #  $hicolor = $hicolor eq '#eeeeee' ? '#eeee66' : '#ffff99';
-%     #} elsif ( $diff ) {
 %       $hicolor = $hicolor eq '#eeeeee' ? '#eeee99' : '#ffffcc';
 %     }
 %   }
@@ -94,16 +91,19 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
       <<%$td%>><% $region->{'label'} %></TD>
       <<%$td%> ALIGN="right">
         <A HREF="<% $baselink. $link %>;nottax=1"
-        ><% &$money_sprintf( $region->{'total'} ) %></A>
+        ><% &$money_sprintf( $region->{'sales'} ) %></A>
       </TD>
+%     if ( $region->{'label'} eq $out ) {
+      <<%$td%> COLSPAN=12></TD>
+%     } else { #not $out
       <<%$td%>><FONT SIZE="+1"><B> - </B></FONT></TD>
       <<%$td%> ALIGN="right">
-        <A HREF="<% $baselink. $link %>;nottax=1;cust_tax=Y"
+        <A HREF="<% $baselink. $link %>;nottax=1;exempt_cust=Y"
         ><% &$money_sprintf( $region->{'exempt_cust'} ) %></A>
       </TD>
       <<%$td%>><FONT SIZE="+1"><B> - </B></FONT></TD>
       <<%$td%> ALIGN="right">
-        <A HREF="<% $baselink. $link %>;nottax=1;pkg_tax=Y"
+        <A HREF="<% $baselink. $link %>;nottax=1;exempt_pkg=Y"
         ><% &$money_sprintf( $region->{'exempt_pkg'} ) %></A>
       </TD>
       <<%$td%>><FONT SIZE="+1"><B> - </B></FONT></TD>
@@ -122,12 +122,24 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
       <<%$tdh%> ALIGN="right">
         <% &$money_sprintf( $region->{'owed'} ) %>
       </TD>
-
-% unless ( $cgi->param('show_taxclasses') ) { 
+%   } # if !$out
+%   unless ( $cgi->param('show_taxclasses') ) { 
 %       my $invlink = $region->{'url_param_inv'}
 %                       ? ';'. $region->{'url_param_inv'}
 %                       : $link;
 
+%     if ( $region->{'label'} eq $out ) {
+        <<%$td%> ALIGN="right">
+          <A HREF="<% $baselink. $invlink %>;istax=1"
+          ><% &$money_sprintf_nonzero( $region->{'tax'} ) %></A>
+        </TD>
+        <<%$td%>></TD>
+        <<%$td%> ALIGN="right">
+          <A HREF="<% $creditlink. $invlink %>;istax=1"
+          ><% &$money_sprintf_nonzero( $region->{'credit'} ) %></A>
+        </TD>
+        <<%$td%> COLSPAN=2></TD>
+%     } else { #not $out
         <<%$tdh%> ALIGN="right">
           <A HREF="<% $baselink. $invlink %>;istax=1"
           ><% &$money_sprintf( $region->{'tax'} ) %></A>
@@ -141,7 +153,8 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
         <<%$tdh%> ALIGN="right">
           <% &$money_sprintf( $region->{'tax'} - $region->{'credit'} ) %>
         </TD>
-% } 
+%   }
+% } # not $out
 
     </TR>
 % } 
@@ -190,6 +203,18 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
 
       <TR>
         <<%$td%>><% $region->{'label'} %></TD>
+%     if ( $region->{'label'} eq $out ) {
+        <<%$td%> ALIGN="right">
+          <A HREF="<% $baselink. $invlink %>;istax=1"
+          ><% &$money_sprintf_nonzero( $region->{'tax'} ) %></A>
+        </TD>
+        <<%$td%>></TD>
+        <<%$td%> ALIGN="right">
+          <A HREF="<% $creditlink. $invlink %>;istax=1"
+          ><% &$money_sprintf_nonzero( $region->{'credit'} ) %></A>
+        </TD>
+        <<%$td%> COLSPAN=2></TD>
+%     } else { #not $out
         <<%$td%> ALIGN="right">
           <A HREF="<% $baselink. $link %>;istax=1"
           ><% &$money_sprintf( $region->{'tax'} ) %></A>
@@ -204,70 +229,52 @@ as <A HREF="<% $p.'search/report_tax-xls.cgi?'.$cgi->query_string%>">Excel sprea
           <% &$money_sprintf( $region->{'tax'} - $region->{'credit'} ) %>
         </TD>
       </TR>
-
-% } 
-
-% if ( $bgcolor eq $bgcolor1 ) {
-%   $bgcolor = $bgcolor2;
-% } else {
-%   $bgcolor = $bgcolor1;
-% }
-% my $td = qq(TD CLASS="grid" BGCOLOR="$bgcolor");
-
-  <TR>
-   <<%$td%>>Total</TD>
-   <<%$td%> ALIGN="right">
-     <A HREF="<% $baselink %>;istax=1"
-     ><% &$money_sprintf( $tot_tax ) %></A>
-   </TD>
-        <<%$td%>><FONT SIZE="+1"><B> - </B></FONT></TD>
-   <<%$td%> ALIGN="right">
-     <A HREF="<% $creditlink %>;istax=1"
-     ><% &$money_sprintf( $tot_credit ) %></A>
-   </TD>
-        <<%$td%>><FONT SIZE="+1"><B> = </B></FONT></TD>
-   <<%$td%> ALIGN="right">
-     <% &$money_sprintf( $tot_tax - $tot_credit ) %>
-   </TD>
-  </TR>
+%     } # if $out
+%   } #foreach $region
 
   </TABLE>
 
-% } 
+% } # if show_taxclasses
 
 <% include('/elements/footer.html') %>
 
 <%init>
 
-my $DEBUG = $cgi->param('debug') || 0;
-
 die "access denied"
   unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
 
+my $DEBUG = $cgi->param('debug') || 0;
+
 my $conf = new FS::Conf;
 
-my $user = getotaker;
+my $out = 'Out of taxable region(s)';
+
+my %label_opt = ( out => 1 ); #enable 'Out of Taxable Region' label
+$label_opt{no_city} = 1     unless $cgi->param('show_cities');
+$label_opt{no_taxclass} = 1 unless $cgi->param('show_taxclasses');
 
 my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
 
 my $join_cust =     '     JOIN cust_bill      USING ( invnum  ) 
                       LEFT JOIN cust_main     USING ( custnum ) ';
+
 my $join_cust_pkg = $join_cust.
                     ' LEFT JOIN cust_pkg      USING ( pkgnum  )
-                      LEFT JOIN part_pkg      USING ( pkgpart ) 
-                      LEFT JOIN cust_location 
-                        ON ( cust_location.locationnum = ' .
-                        FS::cust_pkg->tax_locationnum_sql . ' )';
+                      LEFT JOIN part_pkg      USING ( pkgpart ) ';
 
 my $from_join_cust_pkg = " FROM cust_bill_pkg $join_cust_pkg "; 
 
-my $where = "WHERE _date >= $beginning AND _date <= $ending ";
+# either or both of these can be used to link cust_bill_pkg to cust_main_county
+my $pkg_tax = "SELECT SUM(amount) as tax_amount, invnum, taxnum, ".
+  "cust_bill_pkg_tax_location.pkgnum ".
+  "FROM cust_bill_pkg_tax_location JOIN cust_bill_pkg USING (billpkgnum) ".
+  "GROUP BY billpkgnum, invnum, taxnum, cust_bill_pkg_tax_location.pkgnum";
 
-# this query will be run once per cust_main_county,
-# or maybe once per country/state/city tuple,
-# or maybe once per country/state...it's hard to say.
-my ($location_sql, @base_param) = FS::cust_location->in_county_sql(param => 1);
-$where .= " AND $location_sql ";
+my $pkg_tax_exempt = "SELECT SUM(amount) AS exempt_charged, billpkgnum, taxnum ".
+  "FROM cust_tax_exempt_pkg EXEMPT_WHERE GROUP BY billpkgnum, taxnum";
+
+my $where = "WHERE _date >= $beginning AND _date <= $ending ";
+my $group = "GROUP BY cust_main_county.taxnum";
 
 my $agentname = '';
 if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
@@ -277,270 +284,188 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
   $where .= ' AND cust_main.agentnum = '. $agent->agentnum;
 }
 
-sub gotcust {
-  my $table = shift;
-  my $prefix = @_ ? shift : '';
-  "
-        ( $table.district = cust_main_county.district
-          OR cust_main_county.district = ''
-          OR cust_main_county.district IS NULL )
-    AND ( $table.${prefix}city  = cust_main_county.city
-          OR cust_main_county.city = ''
-          OR cust_main_county.city IS NULL )
-    AND ( $table.${prefix}county  = cust_main_county.county
-          OR cust_main_county.county = ''
-          OR cust_main_county.county IS NULL )
-    AND ( $table.${prefix}state   = cust_main_county.state
-          OR cust_main_county.state = ''
-          OR cust_main_county.state IS NULL )
-    AND ( $table.${prefix}country = cust_main_county.country )
-  ";
-}
-
-#non-parameterized form
-my $location_in_county = FS::cust_location->in_county_sql;
-my $gotcust = "WHERE EXISTS(
-  SELECT 1 FROM cust_location WHERE $location_in_county AND disabled IS NULL
+my $nottax = 'cust_bill_pkg.pkgnum != 0';
+
+# one query for each column of the report
+# plus separate queries for the totals row
+my (%sql, %all_sql);
+
+# general form
+my $exempt = "SELECT cust_main_county.taxnum, SUM(exempt_charged)
+  FROM cust_main_county
+  JOIN ($pkg_tax_exempt) AS pkg_tax_exempt
+  USING (taxnum)
+  JOIN cust_bill_pkg USING (billpkgnum)
+  $join_cust $where AND $nottax $group";
+
+my $all_exempt = "SELECT SUM(exempt_charged)
+  FROM cust_main_county
+  JOIN ($pkg_tax_exempt) AS pkg_tax_exempt
+  USING (taxnum)
+  JOIN cust_bill_pkg USING (billpkgnum)
+  $join_cust $where AND $nottax";
+
+# sales to tax-exempt customers
+$sql{exempt_cust} = $exempt;
+$sql{exempt_cust} =~ s/EXEMPT_WHERE/WHERE exempt_cust = 'Y' OR exempt_cust_taxname = 'Y'/;
+$all_sql{exempt_cust} = $all_exempt;
+$all_sql{exempt_cust} =~ s/EXEMPT_WHERE/WHERE exempt_cust = 'Y' OR exempt_cust_taxname = 'Y'/;
+
+# sales of tax-exempt packages
+$sql{exempt_pkg} = $exempt;
+$sql{exempt_pkg} =~ s/EXEMPT_WHERE/WHERE exempt_setup = 'Y' OR exempt_recur = 'Y'/;
+$all_sql{exempt_pkg} = $all_exempt;
+$all_sql{exempt_pkg} =~ s/EXEMPT_WHERE/WHERE exempt_setup = 'Y' OR exempt_recur = 'Y'/;
+
+# monthly per-customer exemptions
+$sql{exempt_monthly} = $exempt;
+$sql{exempt_monthly} =~ s/EXEMPT_WHERE/WHERE exempt_monthly = 'Y'/;
+$all_sql{exempt_monthly} = $all_exempt;
+$all_sql{exempt_monthly} =~ s/EXEMPT_WHERE/WHERE exempt_monthly = 'Y'/;
+
+# taxable sales
+$sql{taxable} = "SELECT cust_main_county.taxnum, 
+  SUM(cust_bill_pkg.setup + cust_bill_pkg.recur - COALESCE(exempt_charged, 0))
+  FROM cust_main_county
+  JOIN ($pkg_tax) AS pkg_tax USING (taxnum)
+  JOIN cust_bill_pkg USING (invnum, pkgnum)
+  LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt
+    ON (pkg_tax_exempt.billpkgnum = cust_bill_pkg.billpkgnum 
+        AND pkg_tax_exempt.taxnum = cust_main_county.taxnum)
+  $join_cust $where AND $nottax $group";
+
+# Here we're going to sum all line items that are taxable _at all_,
+# under any tax.  exempt_charged is the sum of all exemptions for a 
+# particular billpkgnum + taxnum; we take the taxnum that has the 
+# smallest sum of exemptions and subtract that from the charged amount.
+$all_sql{taxable} = "SELECT
+  SUM(cust_bill_pkg.setup + cust_bill_pkg.recur - COALESCE(min_exempt, 0))
+  FROM cust_bill_pkg
+  JOIN (
+    SELECT invnum, pkgnum, MIN(exempt_charged) AS min_exempt
+    FROM ($pkg_tax) AS pkg_tax
+    JOIN cust_bill_pkg USING (invnum, pkgnum)
+    LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt USING (billpkgnum, taxnum)
+    GROUP BY invnum, pkgnum
+  ) AS pkg_is_taxable 
+  USING (invnum, pkgnum)
+  $join_cust $where AND $nottax";
+  # we don't join pkg_tax_exempt.taxnum here, because
+
+$sql{taxable} =~ s/EXEMPT_WHERE//; # unrestricted
+$all_sql{taxable} =~ s/EXEMPT_WHERE//;
+
+# there isn't one for 'sales', because we calculate sales by adding up 
+# the taxable and exempt columns.
+
+# sum of billed tax:
+# join cust_bill_pkg to cust_main_county via cust_bill_pkg_tax_location
+my $taxfrom = " FROM cust_bill_pkg 
+                $join_cust 
+                LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
+                LEFT JOIN cust_main_county USING ( taxnum )";
+
+my $istax = "cust_bill_pkg.pkgnum = 0";
+my $named_tax = "(
+  taxname = itemdesc
+  OR ( taxname IS NULL 
+    AND ( itemdesc IS NULL OR itemdesc = '' OR itemdesc = 'Tax' )
+  )
 )";
 
-my $out = 'Out of taxable region(s)';
-# these are actually tax labels, not regions
-my %regions = ();
-
-# Phase 1: Taxable and exempt sales
-# Collect for each cust_main_county, and assign to a bin based on label.
-# Note that "label" includes city if show_cities is on, and taxclass if
-# show_taxclasses is on.
-foreach my $r ( qsearch({ 'table'     => 'cust_main_county',
-                          'extra_sql' => $gotcust,
-                          'debug' => $DEBUG,
-                       })
-              )
-{
-  warn $r->county. ' '. $r->state. ' '. $r->country. "\n" if $DEBUG > 1;
-
-  # set up a %regions entry for this region's tax label
-  my $label = getlabel($r);
-  $regions{$label}->{'label'} = $label;
-
-  $regions{$label}->{$_} = $r->$_() for (qw( county state country )); #taxname?
-
-  my @url_param = qw( county state country taxname );
-  push @url_param, 'city' if $cgi->param('show_cities') && $r->city();
-
-  $regions{$label}->{'url_param'} =
-    join(';', map "$_=".uri_escape($r->$_()), @url_param );
-
-  my @param = @base_param;
-  my $mywhere = $where;
-
-  if ( $r->taxclass ) {
-
-    $mywhere .= " AND taxclass = ? ";
-    push @param, 'taxclass';
-    $regions{$label}->{'url_param'} .= ';taxclass='. uri_escape($r->taxclass);
-    #no, always#  if $cgi->param('show_taxclasses');
-
-    $regions{$label}->{'taxclass'} = $r->taxclass;
-
-  } else {
-
-    # SQL for "taxclass doesn't match any other tax in the region"
-    my $same_sql = $r->sql_taxclass_sameregion;
-    $mywhere .= " AND $same_sql" if $same_sql;
-
-    $regions{$label}->{'url_param'} .= ';taxclassNULL=1'
-      if $cgi->param('show_taxclasses')
-      || $same_sql;
-
-  }
-
-  # FROM cust_bill_pkg JOIN (whatever is needed to determine tax location)
-  # WHERE (matches tax location and agentnum and taxclass)
-  # takes parameters in @base_param, plus taxclass if there is one
-  my $fromwhere = "$from_join_cust_pkg $mywhere"; # AND payby != 'COMP' ";
-
-  my $nottax = 'pkgnum != 0';
-
-  ## calculate total of sales (non-tax line items) for this region
-
-  my $t_sql =
-   "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur) $fromwhere AND $nottax";
-  my $t = scalar_sql($r, \@param, $t_sql);
-  $regions{$label}->{'total'} += $t;
-
-  #$regions{$label}->{subtotals}->{$r->taxnum} = $t; #useful debug
-
-  ## calculate customer-exemption for this region
-
-  #false laziness -ish w/report_tax.cgi
-  my $cust_exempt;
-  if ( $r->taxname ) {
-    my $q_taxname = dbh->quote($r->taxname);
-    $cust_exempt =
-      "( tax = 'Y'
-         OR EXISTS ( SELECT 1 FROM cust_main_exemption
-                       WHERE cust_main_exemption.custnum = cust_main.custnum
-                         AND cust_main_exemption.taxname = $q_taxname
-                   )
-       )
-      ";
-  } else {
-    $cust_exempt = " tax = 'Y' ";
-  }
-
-  my $x_cust = scalar_sql($r, \@param,
-    "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur)
-     $fromwhere AND $nottax AND $cust_exempt "
-  );
-
-  $regions{$label}->{'exempt_cust'} += $x_cust;
-  
-  ## calculate package-exemption for this region
-
-  my $x_pkg = scalar_sql($r, \@param,
-    "SELECT SUM(
-                 ( CASE WHEN part_pkg.setuptax = 'Y'
-                        THEN cust_bill_pkg.setup
-                        ELSE 0
-                   END
-                 )
-                 +
-                 ( CASE WHEN part_pkg.recurtax = 'Y'
-                        THEN cust_bill_pkg.recur
-                        ELSE 0
-                   END
-                 )
-               )
-       $fromwhere
-       AND $nottax
-       AND (
-                ( part_pkg.setuptax = 'Y' AND cust_bill_pkg.setup > 0 )
-             OR ( part_pkg.recurtax = 'Y' AND cust_bill_pkg.recur > 0 )
-           )
-       AND ( tax != 'Y' OR tax IS NULL )
-    "
-  );
-  $regions{$label}->{'exempt_pkg'} += $x_pkg;
-
-  ## calculate monthly exemption (texas tax) for this region
-
-  # count up all the cust_tax_exempt_pkg records associated with
-  # the actual line items.
-
-  my $x_monthly = scalar_sql($r, \@param,
-    "SELECT SUM(amount)
-       FROM cust_tax_exempt_pkg
-       JOIN cust_bill_pkg USING ( billpkgnum )
-       $join_cust_pkg
-     $mywhere"
-  );
-  $regions{$label}->{'exempt_monthly'} += $x_monthly;
-
-  my $taxable = $t - $x_cust - $x_pkg - $x_monthly;
-  $regions{$label}->{'taxable'} += $taxable;
-
-  $regions{$label}->{'owed'} += $taxable * ($r->tax/100);
-
-  if ( defined($regions{$label}->{'rate'})
-       && $regions{$label}->{'rate'} != $r->tax.'%' ) {
-    $regions{$label}->{'rate'} = 'variable';
-  } else {
-    $regions{$label}->{'rate'} = $r->tax.'%';
-  }
+$sql{tax} = "SELECT cust_main_county.taxnum, 
+             SUM(cust_bill_pkg_tax_location.amount)
+             $taxfrom
+             $where AND $istax AND $named_tax
+             $group";
+
+$all_sql{tax} = "SELECT SUM(cust_bill_pkg.setup)
+             FROM cust_bill_pkg
+             $join_cust
+             $where AND $istax";
+
+# sum of credits applied against billed tax
+my $creditfrom = $taxfrom .
+   ' JOIN cust_credit_bill_pkg USING (billpkgtaxlocationnum)';
+my $creditfromwhere = $where . 
+   ' AND billpkgtaxratelocationnum IS NULL';
+
+$sql{credit} = "SELECT cust_main_county.taxnum,
+                SUM(cust_credit_bill_pkg.amount)
+                $creditfrom
+                $creditfromwhere AND $istax AND $named_tax
+                $group";
+
+$all_sql{credit} = "SELECT SUM(cust_credit_bill_pkg.amount)
+                FROM cust_credit_bill_pkg
+                JOIN cust_bill_pkg USING (billpkgnum)
+                $join_cust
+                $where AND $istax";
+
+my %data;
+my %total = (owed => 0);
+foreach my $k (keys(%sql)) {
+  my $stmt = $sql{$k};
+  warn "\n".uc($k).":\n".$stmt."\n" if $DEBUG;
+  my $sth = dbh->prepare($stmt);
+  # two columns => key/value
+  $sth->execute 
+    or die "failed to execute $k query: ".$sth->errstr;
+  $data{$k} = +{ map { @$_ } @{ $sth->fetchall_arrayref([]) } };
+
+  warn "\n".$all_sql{$k}."\n" if $DEBUG;
+  $total{$k} = FS::Record->scalar_sql( $all_sql{$k} );
+  warn Dumper($data{$k}) if $DEBUG > 1;
 }
-warn Dumper(\%regions) if $DEBUG > 1;
-# $regions{$label} now contains 'total', 'exempt_cust', 'exempt_pkg', 
-# 'exempt_monthly', summed over each set of regions with the same label.
-
-my $distinct = "country, state, county, city, district,
-                CASE WHEN taxname IS NULL THEN '' ELSE taxname END AS taxname";
-my $taxclass_distinct = 
-  #a little bit unsure of this part... test?
-  #ah, it looks like it winds up being irrelevant as ->{'tax'} 
-  # from $regions is not displayed when show_taxclasses is on
-  ( $cgi->param('show_taxclasses')
-      ? " CASE WHEN taxclass IS NULL THEN '' ELSE taxclass END "
-      : " '' "
-  )." AS taxclass";
-
-
-# Phase 2: invoiced/credited tax items
-# Collect this data for each country/state/city/district/taxname(/taxclass).
-my %qsearch = (
-  'select'    => "DISTINCT $distinct, $taxclass_distinct",
-  'table'     => 'cust_main_county',
-  'hashref'   => {},
-  'extra_sql' => $gotcust,
-  'debug' => $DEBUG,
+# so $data{tax}, for example, is now a hash with one entry
+# for each taxnum, containing the tax billed on that taxnum.
+
+# oddball cases:
+# "out of taxable region" sales
+my %out;
+my $out_sales_sql = 
+  "SELECT SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)
+  FROM (cust_bill_pkg $join_cust)
+  LEFT JOIN ($pkg_tax) AS pkg_tax USING (invnum, pkgnum)
+  LEFT JOIN ($pkg_tax_exempt) AS pkg_tax_exempt USING (billpkgnum)
+  $where AND $nottax
+  AND pkg_tax.taxnum IS NULL AND pkg_tax_exempt.taxnum IS NULL"
+;
+
+$out_sales_sql =~ s/EXEMPT_WHERE//;
+
+$out{sales} = FS::Record->scalar_sql($out_sales_sql);
+
+# unlinked tax collected (for diagnostics)
+my $out_tax_sql =
+  "SELECT SUM(cust_bill_pkg.setup)
+  FROM (cust_bill_pkg $join_cust)
+  LEFT JOIN cust_bill_pkg_tax_location USING (billpkgnum)
+  $where AND $istax AND cust_bill_pkg_tax_location.billpkgnum IS NULL"
+;
+$out{tax} = FS::Record->scalar_sql($out_tax_sql);
+# unlinked tax credited (for diagnostics)
+my $out_credit_sql =
+  "SELECT SUM(cust_credit_bill_pkg.amount)
+  FROM cust_credit_bill_pkg
+  JOIN cust_bill_pkg USING (billpkgnum)
+  $join_cust
+  $where AND $istax AND cust_credit_bill_pkg.billpkgtaxlocationnum IS NULL"
+;
+$out{credit} = FS::Record->scalar_sql($out_credit_sql);
+
+# all sales
+$total{sales} = FS::Record->scalar_sql(
+  "SELECT SUM(cust_bill_pkg.setup + cust_bill_pkg.recur)
+  FROM cust_bill_pkg $join_cust $where AND $nottax"
 );
 
-# Join to cust_main the same as before (we need agentnum)
-# but not to cust_pkg (because tax line items don't have a package)
-# and then to cust_location via cust_bill_pkg_tax_location
-my $taxfromwhere = "FROM cust_bill_pkg $join_cust 
-                    LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
-                    LEFT JOIN cust_location USING ( locationnum )
-                    ";
-my $taxwhere = $where;
-
-my $creditfromwhere = $taxfromwhere. 
-   " JOIN cust_credit_bill_pkg USING (billpkgnum, billpkgtaxlocationnum)";
-
-$taxfromwhere .= " $taxwhere "; #AND payby != 'COMP' ";
-$creditfromwhere .= " $taxwhere AND billpkgtaxratelocationnum IS NULL"; #AND payby != 'COMP' ";
-
-#should i be a cust_main_county method or something
-# yes. yes, you should.
-
-# $taxfromwhere: Most of a query to find cust_bill_pkg records linked to a 
-# customer matching a given state/county/city/district (and within the date 
-# range for the report).
-# @base_param: A list of the fields from cust_main_county to use as parameters.
-
-# $_taxamount_sub: Takes a cust_main_county and returns the sum of taxes billed
-# within the report period for all customers located in that county.  If 
-# the cust_main_county has a taxname, limits to taxes with that name; otherwise
-# includes all line items with pkgnum = 0 and description either 'Tax' or empty.
-
-my $_taxamount_sub = sub {
-  my $r = shift;
-
-  #match itemdesc if necessary!
-  my $named_tax =
-    $r->taxname
-      ? 'AND itemdesc = '. dbh->quote($r->taxname)
-      : "AND ( itemdesc IS NULL OR itemdesc = '' OR itemdesc = 'Tax' )";
-
-  my $sql = "SELECT SUM(cust_bill_pkg.setup+cust_bill_pkg.recur) ".
-            " $taxfromwhere AND cust_bill_pkg.pkgnum = 0 $named_tax";
-
-  scalar_sql($r, [ @base_param ], $sql );
-};
-
-# $_creditamount_sub: As above, but returns the sum of credits applied 
-
-my $_creditamount_sub = sub {
-  my $r = shift;
-
-  #match itemdesc if necessary!
-  my $named_tax =
-    $r->taxname
-      ? 'AND itemdesc = '. dbh->quote($r->taxname)
-      : "AND ( itemdesc IS NULL OR itemdesc = '' OR itemdesc = 'Tax' )";
-
-  my $sql = "SELECT SUM(cust_credit_bill_pkg.amount) ".
-            " $creditfromwhere AND cust_bill_pkg.pkgnum = 0 $named_tax";
-
-  scalar_sql($r, [ @base_param ], $sql );
-};
-
 #tax-report_groups filtering
 my($group_op, $group_value) = ( '', '' );
 if ( $cgi->param('report_group') =~ /^(=|!=) (.*)$/ ) {
   ( $group_op, $group_value ) = ( $1, $2 );
 }
-my $group_test = sub {
+my $group_test = sub { # to be applied to a tax label
   my $label = shift;
   return 1 unless $group_op; #in case we get called inadvertantly
   if ( $label eq $out ) { #don't display "out of taxable region" in this case
@@ -554,90 +479,83 @@ my $group_test = sub {
   }
 };
 
+# if show_taxclasses is on, %base_regions will contain the same data
+# as %regions, but with taxclasses merged together (and ignoring report_group
+# filtering).
+my (%regions, %base_regions);
 my $tot_tax = 0;
 my $tot_credit = 0;
-#foreach my $label ( keys %regions ) {
-foreach my $r ( qsearch(\%qsearch) ) {
 
-  #warn join('-', map { $r->$_() } qw( country state county taxname ) )."\n";
+my @loc_params = qw(country state county);
+push @loc_params, qw(city district) if $cgi->param('show_cities');
 
-  my $label = getlabel($r);
-  if ( $group_op ) {
-    next unless &{$group_test}($label);
+foreach my $r ( qsearch({ 'table'     => 'cust_main_county', })) {
+  my $taxnum = $r->taxnum;
+  # set up a %regions entry for this region's tax label
+  my $label = $r->label(%label_opt);
+  next if $label eq $out;
+  $regions{$label} ||= { label => $label };
+
+  $regions{$label}->{$_} = $r->get($_) foreach @loc_params;
+  $regions{$label}->{taxnums} ||= [];
+  push @{ $regions{$label}->{taxnums} }, $r->taxnum;
+
+  my %x; # keys are data items (like 'tax', 'exempt_cust', etc.)
+  foreach my $k (keys %data) {
+    next unless exists($data{$k}->{$taxnum});
+    $x{$k} = $data{$k}->{$taxnum};
+    $regions{$label}->{$k} += $x{$k};
+    if ( $k eq 'taxable' or $k =~ /^exempt/ ) {
+      $regions{$label}->{'sales'} += $x{$k};
+    }
   }
 
-  #my $fromwhere = $join_pkg. $where. " AND payby != 'COMP' ";
-  #my @param = @base_param; 
+  my $owed = $data{'taxable'}->{$taxnum} * ($r->tax/100);
+  $regions{$label}->{'owed'} += $owed;
+  $total{'owed'} += $owed;
 
-  my $x = &{$_taxamount_sub}($r);
-
-  $regions{$label}->{'tax'} += $x;
-  $tot_tax += $x unless $cgi->param('show_taxclasses');
-
-  ## calculate credit for this region
-
-  $x = &{$_creditamount_sub}($r);
-
-  $regions{$label}->{'credit'} += $x;
-  $tot_credit += $x unless $cgi->param('show_taxclasses');
-
-}
-
-# Phase 3: Non-taxclassed totals for invoiced/credited tax
-# (If show_taxclasses is not in use, this was phase 2, but it 
-# displays somewhere different.)
-# Don't filter by report_groups.
-my %base_regions = ();
-if ( $cgi->param('show_taxclasses') ) {
-
-  $qsearch{'select'} = "DISTINCT $distinct";
-  foreach my $r ( qsearch(\%qsearch) ) {
-
-    my $x = &{$_taxamount_sub}($r);
-
-    my $base_label = getlabel($r, 'no_taxclass'=>1 );
-    $base_regions{$base_label}->{'label'} = $base_label;
-
-    $base_regions{$base_label}->{'url_param'} =
-      join(';', map "$_=". uri_escape($r->$_()),
-                     qw( county state country taxname )
-          );
-
-    $base_regions{$base_label}->{'tax'} += $x;
-    $tot_tax += $x;
-
-    ## calculate credit for this region
-
-    $x = &{$_creditamount_sub}($r);
-
-    $base_regions{$base_label}->{'credit'} += $x;
-    $tot_credit += $x;
+  if ( defined($regions{$label}->{'rate'})
+       && $regions{$label}->{'rate'} != $r->tax.'%' ) {
+    $regions{$label}->{'rate'} = 'variable';
+  } else {
+    $regions{$label}->{'rate'} = $r->tax.'%';
+  }
 
+  if ( $cgi->param('show_taxclasses') ) {
+    my $base_label = $r->label(%label_opt, 'no_taxclass' => 1);
+    $base_regions{$base_label} ||=
+    {
+      label   => $base_label,
+      tax     => 0,
+      credit  => 0,
+    };
+    $base_regions{$base_label}->{tax}    += $x{tax};
+    $base_regions{$base_label}->{credit} += $x{credit};
   }
 
 }
 
-my @regions = keys %regions;
+my @regions = map { $_->{label} }
+  sort {
+    ($b eq $out) <=> ($a eq $out)
+    or $a->{country} cmp $b->{country}
+    or $a->{state}   cmp $b->{state}
+    or $a->{county}  cmp $b->{county}
+    or $a->{city}    cmp $b->{city}
+  } 
+  grep { $_->{sales} > 0 or $_->{tax} > 0 or $_->{credit} > 0 }
+  values %regions;
 
 #tax-report_groups filtering
 @regions = grep &{$group_test}($_), @regions
   if $group_op;
 
 #calculate totals
-my( $total, $tot_taxable, $tot_owed ) = ( 0, 0, 0 );
-my( $exempt_cust, $exempt_pkg, $exempt_monthly, $tot_credit ) = ( 0, 0, 0, 0 );
 my %taxclasses = ();
 my %county = ();
 my %state = ();
 my %country = ();
-foreach (@regions) {
-  $total          += $regions{$_}->{'total'};
-  $tot_taxable    += $regions{$_}->{'taxable'};
-  $tot_owed       += $regions{$_}->{'owed'};
-  $exempt_cust    += $regions{$_}->{'exempt_cust'};
-  $exempt_pkg     += $regions{$_}->{'exempt_pkg'};
-  $exempt_monthly += $regions{$_}->{'exempt_monthly'};
-  $tot_credit     += $regions{$_}->{'credit'};
+foreach my $label (@regions) {
   $taxclasses{$regions{$_}->{'taxclass'}} = 1
     if $regions{$_}->{'taxclass'};
   $county{$regions{$_}->{'county'}} = 1;
@@ -672,29 +590,27 @@ if ( $group_op ) {
 #ordering
 @regions =
   map $regions{$_},
-  sort { ( ($a eq $out) cmp ($b eq $out) ) || ($b cmp $a) }
+  sort { $a cmp $b }
   @regions;
 
 my @base_regions =
   map $base_regions{$_},
-  sort { ( ($a eq $out) cmp ($b eq $out) ) || ($b cmp $a) }
+  sort { $a cmp $b }
   keys %base_regions;
 
-#add total line
-push @regions, {
-  'label'          => 'Total',
-  'url_param'      => $total_url_param,
-  'url_param_inv'  => $total_url_param_invoiced,
-  'total'          => $total,
-  'exempt_cust'    => $exempt_cust,
-  'exempt_pkg'     => $exempt_pkg,
-  'exempt_monthly' => $exempt_monthly,
-  'taxable'        => $tot_taxable,
-  'rate'           => '',
-  'owed'           => $tot_owed,
-  'tax'            => $tot_tax,
-  'credit'         => $tot_credit,
-};
+#add "Out of taxable" and total lines
+%out = ( %out,
+  'label' => $out,
+  'rate' => ''
+);
+%total = ( %total, 
+  'label'         => 'Total',
+  'url_param'     => $total_url_param,
+  'url_param_inv' => $total_url_param_invoiced,
+  'rate'          => '',
+);
+push @regions, \%out, \%total;
+push @base_regions, \%out, \%total;
 
 #-- 
 
@@ -702,69 +618,15 @@ my $money_char = $conf->config('money_char') || '$';
 my $money_sprintf = sub {
   $money_char. sprintf('%.2f', shift );
 };
-
-sub getlabel {
-  my $r = shift;
-  my %opt = @_;
-
-  my $label;
-  if (
-    $r->tax == 0 
-    && ! scalar( qsearch('cust_main_county', { 'district'=> $r->district,
-                                               'city'    => $r->city,
-                                               'county'  => $r->county,
-                                               'state'   => $r->state,
-                                               'country' => $r->country,
-                                               'tax' => { op=>'>', value=>0 },
-                                             }
-                        )
-               )
-
-  ) {
-    #kludge to avoid "will not stay shared" warning
-    my $out = 'Out of taxable region(s)';
-    $label = $out;
-  } else {
-    $label = $r->country;
-    $label = $r->state.", $label" if $r->state;
-    $label = $r->county." county, $label" if $r->county;
-    $label = $r->city. ", $label" if $r->city && $cgi->param('show_cities');
-    $label = "$label (". $r->taxclass. ")"
-      if $r->taxclass
-      && $cgi->param('show_taxclasses')
-      && ! $opt{'no_taxclass'};
-    $label = $r->taxname. " ($label)" if $r->taxname;
-  }
-  return $label;
-}
-
-#my %count_taxname = (); #cache
-#sub count_taxname {
-#  my $taxname = shift;
-#  return $count_taxname{$taxname} if exists $count_taxname{$taxname};
-#  my $sql = 'SELECT COUNT(*) FROM cust_main_county WHERE taxname = ?';
-#  my $sth = dbh->prepare($sql) or die dbh->errstr;
-#  $sth->execute( $taxname )
-#    or die "Unexpected error executing statement $sql: ". $sth->errstr;
-#  $count_taxname{$taxname} = $sth->fetchrow_arrayref->[0];
-#}
-
-#false laziness w/FS::Report::Table::Monthly (sub should probably be moved up
-#to FS::Report or FS::Record or who the fuck knows where)
-sub scalar_sql {
-  my( $r, $param, $sql ) = @_;
-  #warn "$sql\n";
-  my $sth = dbh->prepare($sql) or die dbh->errstr;
-  $sth->execute( map $r->$_(), @$param )
-    or die "Unexpected error executing statement $sql: ". $sth->errstr;
-  $sth->fetchrow_arrayref->[0] || 0;
-}
+my $money_sprintf_nonzero = sub {
+  $_[0] == 0 ? '' : &$money_sprintf($_[0])
+};
 
 my $dateagentlink = "begin=$beginning;end=$ending";
 $dateagentlink .= ';agentnum='. $cgi->param('agentnum')
   if length($agentname);
 my $baselink   = $p. "search/cust_bill_pkg.cgi?$dateagentlink";
 my $exemptlink = $p. "search/cust_tax_exempt_pkg.cgi?$dateagentlink";
-my $creditlink = $p. "search/cust_credit_bill_pkg.html?$dateagentlink";
+my $creditlink = $p. "search/cust_bill_pkg.cgi?$dateagentlink;credit=1";
 
 </%init>
diff --git a/httemplate/search/sqlradius_usage.html b/httemplate/search/sqlradius_usage.html
new file mode 100644 (file)
index 0000000..29ef4c0
--- /dev/null
@@ -0,0 +1,201 @@
+% if ( @include_agents ) {
+%   # jumbo report
+<& /elements/header.html, $title &>
+%   foreach my $agent ( @include_agents ) {
+% $cgi->param('agentnum', $agent->agentnum); #for download links
+<DIV WIDTH="100%" STYLE="page-break-after: always">
+<FONT SIZE=6><% $agent->agent %></FONT><BR><BR>
+  <& sqlradius_usage.html, 
+      export            => $export,
+      agentnum          => $agent->agentnum,
+      nohtmlheader      => 1,
+      usage_by_username => \%usage_by_username,
+      download_label    => 'Download this section',
+      &>
+</DIV>
+<BR><BR>
+%  }
+<& /elements/footer.html &>
+% } else {
+<& elements/search.html,
+  'title'       => $title,
+  'name'        => 'services',
+  'query'       => $sql_query,
+  'count_query' => $sql_query->{'count_query'},
+  'header'      => [ #FS::UI::Web::cust_header(),
+                     '#',
+                     'Customer',
+                     'Package',
+                     @svc_header,
+                     'Upload (GB)',
+                     'Download (GB)',
+                     'Total (GB)',
+                   ],
+  'footer'      => \@footer,
+  'fields'      => [ #\&FS::UI::Web::cust_fields,
+                     'display_custnum',
+                     'name',
+                     'pkg',
+                     @svc_fields,
+                     @svc_usage,
+                   ],
+  'links'       => [ #( map { $_ ne 'Cust. Status' ? $link_cust : '' }
+                     #  FS::UI::Web::cust_header() ),
+                     $link_cust,
+                     $link_cust,
+                     '', #package
+                     ( map { $link_svc } @svc_header ),
+                     '',
+                     '',
+                     '',
+                   ],
+  'align'       => #FS::UI::Web::cust_aligns() .
+                   'rlc' . ('l' x scalar(@svc_header)) . 'rrr' ,
+  'nohtmlheader'    => ($opt{'nohtmlheader'} || 0),
+  'download_label'  => $opt{'download_label'},
+&>
+% }
+<%init>
+
+my %opt = @_;
+
+die "access denied" unless
+  $FS::CurrentUser::CurrentUser->access_right('List services');
+
+my $title = 'Data Usage Report - '; 
+my $agentnum;
+my @include_agents;
+
+if ( $opt{'agentnum'} ) {
+  $agentnum = $opt{'agentnum'};
+} elsif ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
+  $agentnum = $1;
+}
+
+if ( $agentnum ) {
+  my $agent = FS::agent->by_key($agentnum);
+  $title = $agent->agent." $title";
+} else {
+  @include_agents = qsearch('agent', {});
+}
+
+# usage query params
+my( $beginning, $ending ) = FS::UI::Web::parse_beginning_ending($cgi);
+
+if ( $beginning ) {
+  $title .= time2str('%h %o %Y ', $beginning);
+}
+$title .= 'through ';
+if ( $ending == 4294967295 ) {
+  $title .= 'now';
+} else {
+  $title .= time2str('%h %o %Y', $ending);
+}
+
+my $export;
+my %usage_by_username;
+if ( exists($opt{usage_by_username}) ) {
+  # There's no agent separation in the radacct data.  So in the jumbo report
+  # do this procedure once, and pass the hash into all the per-agent sections.
+  %usage_by_username = %{ $opt{usage_by_username} };
+  $export  = $opt{export};
+} else {
+
+  $cgi->param('exportnum') =~ /^(\d+)$/
+    or die "illegal export: '".$cgi->param('exportnum')."'";
+  $export = FS::part_export->by_key($1)
+    or die "exportnum $1 not found";
+  $export->exporttype =~ /sqlradius/
+    or die "exportnum ".$export->exportnum." is type ".$export->exporttype.
+           ", not sqlradius";
+
+  my $usage = $export->usage_sessions( {
+      stoptime_start  => $beginning,
+      stoptime_end    => $ending,
+      summarize       => 1
+  } );
+  # arrayref of hashrefs of
+  # (username, acctsessiontime, acctinputoctets, acctoutputoctets)
+  # (XXX needs to include 'realm' for sqlradius_withdomain)
+  # rearrange to be indexed by username.
+
+  foreach (@$usage) {
+    my $username = $_->{'username'};
+    my @row = (
+      $_->{'acctinputoctets'},
+      $_->{'acctoutputoctets'},
+      $_->{'acctinputoctets'} + $_->{'acctoutputoctets'}
+    );
+    $usage_by_username{$username} = \@row;
+  }
+}
+
+#warn Dumper(\%usage_by_username);
+my @total_usage = (0, 0, 0, 0); # session time, input, output, input + output
+my @svc_usage = map {
+  my $i = $_;
+  sub {
+    my $username = $export->export_username(shift);
+    return '' if !exists($usage_by_username{$username});
+    my $value = $usage_by_username{ $username }->[$i];
+    $total_usage[$i] += $value;
+    # for now, always show in GB, rounded to 3 digits
+    bytes_to_gb($value);
+  }
+} (0,1,2);
+
+# set up svcdb-specific stuff
+my $export_username = sub {
+  $export->export_username(shift); # countrycode + phone, formatted MAC, etc.
+};
+
+my %svc_header = (
+  svc_acct      => [ 'Username' ],
+  svc_broadband => [ 'MAC address', 'IP address' ],
+#  svc_phone     => [ 'Phone' ], #not yet supported, no search method
+                                 # (not sure input/output octets is relevant)
+);
+my %svc_fields = (
+  svc_acct      => [ $export_username ],
+  svc_broadband => [ $export_username, 'ip_addr' ],
+#  svc_phone     => [ $export_username ],
+);
+
+# what kind of service we're operating on
+my $svcdb = FS::part_export::export_info()->{$export->exporttype}->{'svc'};
+my $class = "FS::$svcdb";
+my @svc_header = @{ $svc_header{$svcdb} };
+my @svc_fields = @{ $svc_fields{$svcdb} };
+
+# svc_x search params
+my %search_hash = ( 'agentnum' => $agentnum,
+                    'exportnum' => $export->exportnum );
+
+my $sql_query = $class->search(\%search_hash);
+$sql_query->{'select'}    .= ', part_pkg.pkg';
+$sql_query->{'addl_from'} .= ' LEFT JOIN part_pkg USING (pkgpart)';
+
+my $link_svc = [ $p.'view/cust_svc.cgi?', 'svcnum' ];
+
+my $link_cust = [ $p.'view/cust_main.cgi?', 'custnum' ];
+
+# columns between the customer name and the usage fields
+my $skip_cols = 1 + scalar(@svc_header);
+
+my @footer = (
+  '',
+  FS::Record->scalar_sql($sql_query->{count_query}) . ' services',
+  ('') x $skip_cols,
+  map {
+    my $i = $_;
+    sub { # defer this until the rows have been processed
+      bytes_to_gb($total_usage[$i])
+    }
+  } (0,1,2)
+);
+
+sub bytes_to_gb {
+  $_[0] ?  sprintf('%.3f', $_[0] / (1024*1024*1024.0)) : '';
+}
+
+</%init>
index a8b4ac1..95ce60b 100755 (executable)
@@ -166,8 +166,6 @@ die "Invoice #$invnum not found!" unless $cust_bill;
 my $custnum = $cust_bill->custnum;
 my $display_custnum = $cust_bill->cust_main->display_custnum;
 
-#my $printed = $cust_bill->printed;
-
 my $link = "invnum=$invnum";
 $link .= ';template='. uri_escape($template) if $template;
 $link .= ';notice_name='. $notice_name if $notice_name;
diff --git a/httemplate/view/cust_bill_void.html b/httemplate/view/cust_bill_void.html
new file mode 100755 (executable)
index 0000000..2c52674
--- /dev/null
@@ -0,0 +1,79 @@
+<& /elements/header.html, mt('Voided Invoice'),  menubar(
+  emt("View this customer (#[_1])",$display_custnum) => "${p}view/cust_main.cgi?$custnum",
+) &>
+
+<SCRIPT TYPE="text/javascript">
+function areyousure(href, message) {
+  if (confirm(message) == true)
+    window.location.href = href;
+}
+</SCRIPT>
+<% areyousure_link("${p}misc/unvoid-cust_bill_void.html?invnum=". $cust_bill_void->invnum,
+                     emt('Are you sure you want to unvoid this invoice?'),
+                     emt('Unvoid this invoice'), #tooltip
+                     emt('Unvoid this invoice') #link
+                  )
+%>
+<BR><BR>
+
+% #voided PDFs?
+% #if ( $conf->exists('invoice_latex') ) {
+%#
+%#  <A HREF="<% $p %>view/cust_bill-pdf.cgi?<% $link %>"><% mt('View typeset invoice PDF') |h %></A>
+%#  <BR><BR>
+% #} 
+
+%#something very big and obvious showing its voided...
+<DIV STYLE="color:#FF0000; font-size:1000%; font-weight:bold; z-index:100;
+            position: absolute; top: 300px; left: 130px;
+            zoom: 1; filter: alpha(opacity=25); opacity: 0.25;
+">VOID</DIV>
+
+% if ( $conf->exists('invoice_html') ) { 
+  <% join('', $cust_bill_void->print_html(\%opt) ) %>
+% } else { 
+  <PRE><% join('', $cust_bill_void->print_text(\%opt) ) %></PRE>
+% } 
+
+<& /elements/footer.html &>
+<%init>
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+  unless $curuser->access_right('View invoices');
+
+my $invnum;
+my($query) = $cgi->keywords;
+if ( $query =~ /^(\d+)$/ ) {
+  $invnum = $1;
+} else {
+  $invnum = $cgi->param('invnum');
+}
+
+my $conf = new FS::Conf;
+
+my %opt = (
+  'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
+);
+
+my $cust_bill_void = qsearchs({
+  'select'    => 'cust_bill_void.*',
+  'table'     => 'cust_bill_void',
+  #'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
+  'hashref'   => { 'invnum' => $invnum },
+  #'extra_sql' => ' AND '. $curuser->agentnums_sql,
+});
+die "Voided invoice #$invnum not found!" unless $cust_bill_void;
+
+my $custnum = $cust_bill_void->custnum;
+my $display_custnum = $cust_bill_void->cust_main->display_custnum;
+
+#my $link = "invnum=$invnum";
+
+sub areyousure_link {
+    my ($url,$msg,$title,$label) = (shift,shift,shift,shift);
+    '<A HREF="javascript:areyousure(\''.$url.'\',\''.$msg.'\')" TITLE="'.$title.'">'.$label.'</A>';
+}
+
+</%init>
index b2a0efd..5c46803 100644 (file)
   <TD BGCOLOR="#ffffff"><B><% $balance %></B></TD>
 </TR>
 
+% if ( $conf->exists('cust_main-select-prorate_day') ) {
+<TR>
+  <TD ALIGN="right"><% mt('Prorate day of month') |h %></TD>
+  <TD BGCOLOR="#ffffff"><% $cust_main->prorate_day %>
+  </TD>
+</TR>
+% }
+
 % if ( $conf->exists('cust_main-select-billday') 
 %    && ($cust_main->payby eq 'CARD' || $cust_main->payby eq 'CHEK') ) {
 <TR>
index 9c60691..d65af66 100644 (file)
@@ -1,13 +1,18 @@
 % my %addr_label = ('bill' => 'Billing address', 'ship' => 'Service address');
 
 %# Locations (possibly break this out)
-% my @which = ('bill');
-% push @which, 'ship' if $cust_main->has_ship_address;
+% my @which = ('bill', 'ship');
 % while (@which) {
 %   my $this = shift @which;
 %   my $method = $this.'_location';
 %   my $location = $cust_main->$method;
-<FONT CLASS="fsinnerbox-title"><% mt( $addr_label{$this} ) |h %></FONT>
+<FONT CLASS="fsinnerbox-title"><% mt( $addr_label{$this} ) |h %>
+%   if ( $this eq 'ship' and 
+%       $cust_main->bill_locationnum == $cust_main->ship_locationnum )
+%   {
+ (<% mt('same as billing') %>)
+%   }
+</FONT>
 <TABLE CLASS="fsinnerbox">
 
 % if ( $this eq 'bill' ) {
diff --git a/httemplate/view/cust_main/custom_content/.birthdate.html.swp b/httemplate/view/cust_main/custom_content/.birthdate.html.swp
deleted file mode 100644 (file)
index 9571d22..0000000
Binary files a/httemplate/view/cust_main/custom_content/.birthdate.html.swp and /dev/null differ
diff --git a/httemplate/view/cust_main/custom_content/.small_custview.html.swp b/httemplate/view/cust_main/custom_content/.small_custview.html.swp
deleted file mode 100644 (file)
index a39f52d..0000000
Binary files a/httemplate/view/cust_main/custom_content/.small_custview.html.swp and /dev/null differ
diff --git a/httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp b/httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp
deleted file mode 100644 (file)
index 0042012..0000000
Binary files a/httemplate/view/cust_main/custom_content/.spouse_birthdate.html.swp and /dev/null differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_Common.html.swp b/httemplate/view/cust_main/custom_content/.svc_Common.html.swp
deleted file mode 100644 (file)
index 15591b9..0000000
Binary files a/httemplate/view/cust_main/custom_content/.svc_Common.html.swp and /dev/null differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_acct.html.swp b/httemplate/view/cust_main/custom_content/.svc_acct.html.swp
deleted file mode 100644 (file)
index e2db6d5..0000000
Binary files a/httemplate/view/cust_main/custom_content/.svc_acct.html.swp and /dev/null differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_hardware.html.swp b/httemplate/view/cust_main/custom_content/.svc_hardware.html.swp
deleted file mode 100644 (file)
index 1106f9e..0000000
Binary files a/httemplate/view/cust_main/custom_content/.svc_hardware.html.swp and /dev/null differ
diff --git a/httemplate/view/cust_main/custom_content/.svc_phone.html.swp b/httemplate/view/cust_main/custom_content/.svc_phone.html.swp
deleted file mode 100644 (file)
index 79b8185..0000000
Binary files a/httemplate/view/cust_main/custom_content/.svc_phone.html.swp and /dev/null differ
index a0ab403..263c266 100644 (file)
     <TD BGCOLOR="#ffffff"><% $cust_main->signupdate ? time2str($date_format, $cust_main->signupdate) : '' %></TD>
   </TR>
 
+% my $id_country = $conf->config('national_id-country');
+%  if ( $id_country ) {
+%   if ( $id_country eq 'MY' ) {
+      <TR>
+%     my($old, $nric) = ( '', '');
+%     if ( $cust_main->national_id =~ /^\d{6}\-\d{2}\-\d{4}$/ ) {
+          <TD ALIGN="right"><% mt('NRIC') |h %></TD>
+%     } else { # elsif ( $cust_main->national_id =~ /^\w\d{9}$/ ) {
+          <TD ALIGN="right"><% mt('Old IC/Passport') |h %></TD>
+%     #} else {
+%     #  warn "unknown national_id format";
+%#         <TD ALIGN="right"></TD>
+%     }
+        <TD BGCOLOR="#ffffff"><% $cust_main->national_id |h %></TD>
+      </TR>
+%   } else {
+%     warn "unknown national_id-country $id_country";
+%   }
+% }
+
 % if ( $conf->exists('cust_main-enable_birthdate') ) {
 %   my $dt = $cust_main->birthdate ne ''
 %              ? DateTime->from_epoch( 'epoch'     => $cust_main->birthdate,
 
 % }
 
+% if ( $conf->exists('cust_main-enable_anniversary_date') ) {
+%   my $dt = $cust_main->anniversary_date ne ''
+%              ? DateTime->from_epoch( 'epoch'  => $cust_main->anniversary_date,
+%                                      'time_zone' =>'floating',
+%                                    )
+%              : '';
+
+  <TR>
+    <TD ALIGN="right"><% mt('Anniversary Date') |h %></TD>
+    <TD BGCOLOR="#ffffff"><% $dt ? $dt->strftime($date_format) : '' %></TD>
+  </TR>
+
+% }
+
 % if ( $conf->exists('cust_main-require_censustract') ) {
 
   <TR>
index 9e08c0c..166addb 100644 (file)
 %                  ? sprintf("$money_char\%.2f", $item->{'charge'})
 %                  : exists($item->{'charge_nobal'})
 %                    ? sprintf("$money_char\%.2f", $item->{'charge_nobal'})
-%                    : '';
+%                    : exists($item->{'void_charge'})
+%                      ? sprintf("<DEL>$money_char\%.2f</DEL>", $item->{'void_charge'})
+%                      : '';
 %
 %  my $payment = exists($item->{'payment'})
 %                  ? sprintf("-&nbsp;$money_char\%.2f", $item->{'payment'})
@@ -428,6 +430,15 @@ foreach my $cust_bill ($cust_main->cust_bill) {
   $num_cust_bill++;
 }
 
+#voided invoices
+foreach my $cust_bill_void ($cust_main->cust_bill_void) {
+  push @history, {
+    'date'        => $cust_bill_void->_date,
+    'desc'        => include('payment_history/voided_invoice.html', $cust_bill_void, %opt ),
+    'void_charge' => $cust_bill_void->charged,
+  };
+}
+
 #statements
 foreach my $cust_statement ($cust_main->cust_statement) {
   push @history, {
index 3028f0f..96a9f54 100644 (file)
@@ -1,4 +1,4 @@
-<% $link %><% $invoice %><% $link ? '</A>' : '' %><% $delete %><% $under %>
+<% $link %><% $invoice %><% $link ? '</A>' : '' %><% "$void$delete$under" %>
 <%init>
 
 my( $cust_bill, %opt ) = @_;
@@ -26,6 +26,18 @@ my $link = $curuser->access_right('View invoices')
              ? qq!<A HREF="${p}view/cust_bill.cgi?$invnum">!
              : '';
 
+my $void = '';
+if ( $cust_bill->closed !~ /^Y/i && $curuser->access_right('Void invoices') ) {
+  $void =
+    ' ('. include('/elements/popup_link.html',
+                    'label'     => emt('void'),
+                    'action'    => "${p}misc/void-cust_bill.html?;invnum=".
+                                    $cust_bill->invnum,
+                    'actionlabel' => emt('Void Invoice'),
+                 ).
+     ')';
+}
+
 my $delete = '';
 $delete = areyousure_link("${p}misc/delete-cust_bill.html?$invnum",
                             emt('Are you sure you want to delete this invoice?'),
index d7322a2..ff269bf 100644 (file)
@@ -181,7 +181,7 @@ $void = areyousure_link("${p}misc/void-cust_pay.cgi?".$cust_pay->paynum,
                && $curuser->access_right('Echeck void')
              )
           || ( $cust_pay->payby !~ /^(CARD|CHEK)$/
-               && $curuser->access_right('Regular void')
+               && $curuser->access_right('Void payments')
              )
         )
    );
diff --git a/httemplate/view/cust_main/payment_history/voided_invoice.html b/httemplate/view/cust_main/payment_history/voided_invoice.html
new file mode 100644 (file)
index 0000000..15393cb
--- /dev/null
@@ -0,0 +1,57 @@
+<DEL><% $link %><% $invoice %><% $link ? '</A>' : '' %></DEL>
+<I><% mt("voided [_1]", time2str($date_format, $cust_bill_void->void_date) ) |h %> 
+% my $void_user = $cust_bill_void->void_access_user;
+% if ($void_user) {
+    by <% $void_user->username %></I>
+% }
+<% "$unvoid$delete$under" %>
+<%init>
+
+my( $cust_bill_void, %opt ) = @_;
+
+my $date_format = $opt{'date_format'} || '%m/%d/%Y';
+
+my $conf = new FS::Conf;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+my $invoice = emt("Invoice #[_1] (Balance [_2])",$cust_bill_void->display_invnum, $cust_bill_void->charged);
+
+my $under = '';
+
+my $invnum = $cust_bill_void->invnum;
+
+my $link = $curuser->access_right('View invoices')
+             ? qq!<A HREF="${p}view/cust_bill_void.html?$invnum">!
+             : '';
+
+my $unvoid = '';
+$unvoid = areyousure_link("${p}misc/unvoid-cust_bill_void.html?invnum=". $cust_bill_void->invnum,
+                            emt('Are you sure you want to unvoid this invoice?'),
+                            emt('Unvoid this invoice'),
+                            emt('unvoid')
+                         )
+  if $cust_bill_void->closed !~ /^Y/ && $curuser->access_right('Unvoid invoices');
+
+my $delete = '';
+$delete = areyousure_link("${p}misc/delete-cust_bill.html?$invnum",
+                            emt('Are you sure you want to delete this invoice?'),
+                            emt('Delete this invoice from the database completely'),
+                            emt('delete')
+                        )
+    if ( $opt{'deleteinvoices'} && $curuser->access_right('Delete invoices') );
+
+my $events = '';
+#1.9
+if ( $cust_bill_void->num_cust_event
+     && (    $curuser->access_right('Billing event reports')
+          || $curuser->access_right('View customer billing events')
+        )
+   ) {
+  $under .=
+    qq!<BR><A HREF="${p}search/cust_event.html?invnum=$invnum">( !.
+      emt('View invoice events').' )</A>';
+}
+$under = '<FONT SIZE="-1">'.$under.'</FONT>' if length($under);
+
+</%init>
index 2f038be..88b5e0a 100644 (file)
@@ -31,6 +31,6 @@ $unvoid = areyousure_link("${p}misc/unvoid-cust_pay_void.cgi?".$cust_pay_void->p
                             emt('Unvoid this payment from the database') . $unvoidmsg,
                             emt('unvoid')
                          )
-    if ( $cust_pay_void->closed !~ /^Y/i && $curuser->access_right('Unvoid') );
+    if ( $cust_pay_void->closed !~ /^Y/i && $curuser->access_right('Unvoid payments') );
 
 </%init>
diff --git a/httemplate/view/elements/tr-svc_export_machine.html b/httemplate/view/elements/tr-svc_export_machine.html
new file mode 100644 (file)
index 0000000..1ba8d74
--- /dev/null
@@ -0,0 +1,27 @@
+% foreach my $part_export (@part_export) {
+%   my $label = ( $part_export->exportname
+%                   ? $part_export->exportname
+%                   : $part_export->label
+%               ).
+%               ' hostname';
+%
+%   my $svc_export_machine = qsearchs('svc_export_machine', {
+%     'svcnum'    => $opt{svc}->svcnum,
+%     'exportnum' => $part_export->exportnum,
+%   });
+
+    <& tr.html,
+         'label' => $label,
+         'value' => $svc_export_machine
+                      ? $svc_export_machine->part_export_machine->machine
+                      : '',
+    &>
+% }
+<%init>
+
+my %opt = @_;
+
+my @part_export = grep { $_->machine eq '_SVC_MACHINE' }
+                    $opt{part_svc}->part_export;
+
+</%init>
index 461b5df..a88acf8 100755 (executable)
@@ -44,8 +44,6 @@ XXX resending quotations
 % }
 % #plaintext quotations? <PRE><% join('', $quotation->print_text() ) %></PRE>
 
-</%doc>
-
 <& /elements/footer.html &>
 <%init>
 
index bcd8469..1cdf776 100644 (file)
     &>
 % }
 
+<& /view/elements/tr-svc_export_machine.html,
+     'svc'      => $svc_acct,
+     'part_svc' => $part_svc,
+&>
+
 % if ($svc_acct->uid ne '') { 
   <& /view/elements/tr.html, label=>mt('UID'), value=>$svc_acct->uid &>
 % } 
diff --git a/init.d/insserv-override-apache2 b/init.d/insserv-override-apache2
new file mode 100644 (file)
index 0000000..1b333e8
--- /dev/null
@@ -0,0 +1,11 @@
+### BEGIN INIT INFO
+# Provides:          apache2
+# Required-Start:    $local_fs $remote_fs $network $syslog $named
+# Required-Stop:     $local_fs $remote_fs $network $syslog $named
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# X-Interactive:     true
+# Short-Description: Start/stop apache2 web server
+# Should-Start:      postgresql mysql
+# Should-Stop:       postgresql mysql
+### END INIT INFO
index 32f459a..89873f5 100755 (executable)
--- a/rt/bin/rt
+++ b/rt/bin/rt
@@ -420,7 +420,7 @@ sub show {
         }
         elsif (my $spec = is_object_spec($_, $type)) {
             push @objects, $spec;
-            $rawprint = 1 if $_ =~ /\/content$/ or $_ !~ /^ticket/;
+            $rawprint = 1 if $_ =~ /\/content$/ or $_ =~ /\/links/ or $_ !~ /^ticket/;
         }
         else {
             my $datum = /^-/ ? "option" : "argument";
index e54a07a..2a9f643 100644 (file)
@@ -420,7 +420,7 @@ sub show {
         }
         elsif (my $spec = is_object_spec($_, $type)) {
             push @objects, $spec;
-            $rawprint = 1 if $_ =~ /\/content$/ or $_ !~ /^ticket/;
+            $rawprint = 1 if $_ =~ /\/content$/ or $_ =~ /\/links/ or $_ !~ /^ticket/;
         }
         else {
             my $datum = /^-/ ? "option" : "argument";
index 1862c5f..76ef85b 100755 (executable)
@@ -1,7 +1,7 @@
 #! /bin/sh
 # From configure.ac Revision.
 # Guess values for system-dependent variables and create Makefiles.
-# Generated by GNU Autoconf 2.67 for RT rt-4.0.6.
+# Generated by GNU Autoconf 2.68 for RT rt-4.0.7.
 #
 # Report bugs to <rt-bugs@bestpractical.com>.
 #
@@ -92,6 +92,7 @@ fi
 IFS=" ""       $as_nl"
 
 # Find who we are.  Look in the path if we contain no directory separator.
+as_myself=
 case $0 in #((
   *[\\/]* ) as_myself=$0 ;;
   *) as_save_IFS=$IFS; IFS=$PATH_SEPARATOR
@@ -216,11 +217,18 @@ IFS=$as_save_IFS
   # We cannot yet assume a decent shell, so we have to provide a
        # neutralization value for shells without unset; and this also
        # works around shells that cannot unset nonexistent variables.
+       # Preserve -v and -x to the replacement shell.
        BASH_ENV=/dev/null
        ENV=/dev/null
        (unset BASH_ENV) >/dev/null 2>&1 && unset BASH_ENV ENV
        export CONFIG_SHELL
-       exec "$CONFIG_SHELL" "$as_myself" ${1+"$@"}
+       case $- in # ((((
+         *v*x* | *x*v* ) as_opts=-vx ;;
+         *v* ) as_opts=-v ;;
+         *x* ) as_opts=-x ;;
+         * ) as_opts= ;;
+       esac
+       exec "$CONFIG_SHELL" $as_opts "$as_myself" ${1+"$@"}
 fi
 
     if test x$as_have_required = xno; then :
@@ -552,8 +560,8 @@ MAKEFLAGS=
 # Identity of this package.
 PACKAGE_NAME='RT'
 PACKAGE_TARNAME='rt'
-PACKAGE_VERSION='rt-4.0.6'
-PACKAGE_STRING='RT rt-4.0.6'
+PACKAGE_VERSION='rt-4.0.7'
+PACKAGE_STRING='RT rt-4.0.7'
 PACKAGE_BUGREPORT='rt-bugs@bestpractical.com'
 PACKAGE_URL=''
 
@@ -1165,7 +1173,7 @@ Try \`$0 --help' for more information"
     $as_echo "$as_me: WARNING: you should use --build, --host, --target" >&2
     expr "x$ac_option" : ".*[^-._$as_cr_alnum]" >/dev/null &&
       $as_echo "$as_me: WARNING: invalid host type: $ac_option" >&2
-    : ${build_alias=$ac_option} ${host_alias=$ac_option} ${target_alias=$ac_option}
+    : "${build_alias=$ac_option} ${host_alias=$ac_option} ${target_alias=$ac_option}"
     ;;
 
   esac
@@ -1303,7 +1311,7 @@ if test "$ac_init_help" = "long"; then
   # Omit some internal or obsolete options to make the list less imposing.
   # This message is too long to be a string in the A/UX 3.1 sh.
   cat <<_ACEOF
-\`configure' configures RT rt-4.0.6 to adapt to many kinds of systems.
+\`configure' configures RT rt-4.0.7 to adapt to many kinds of systems.
 
 Usage: $0 [OPTION]... [VAR=VALUE]...
 
@@ -1364,7 +1372,7 @@ fi
 
 if test -n "$ac_init_help"; then
   case $ac_init_help in
-     short | recursive ) echo "Configuration of RT rt-4.0.6:";;
+     short | recursive ) echo "Configuration of RT rt-4.0.7:";;
    esac
   cat <<\_ACEOF
 
@@ -1488,8 +1496,8 @@ fi
 test -n "$ac_init_help" && exit $ac_status
 if $ac_init_version; then
   cat <<\_ACEOF
-RT configure rt-4.0.6
-generated by GNU Autoconf 2.67
+RT configure rt-4.0.7
+generated by GNU Autoconf 2.68
 
 Copyright (C) 2010 Free Software Foundation, Inc.
 This configure script is free software; the Free Software Foundation
@@ -1535,7 +1543,7 @@ sed 's/^/| /' conftest.$ac_ext >&5
 
        ac_retval=1
 fi
-  eval $as_lineno_stack; test "x$as_lineno_stack" = x && { as_lineno=; unset as_lineno;}
+  eval $as_lineno_stack; ${as_lineno_stack:+:} unset as_lineno
   as_fn_set_status $ac_retval
 
 } # ac_fn_c_try_compile
@@ -1581,7 +1589,7 @@ fi
   # interfere with the next link command; also delete a directory that is
   # left behind by Apple's compiler.  We do this before executing the actions.
   rm -rf conftest.dSYM conftest_ipa8_conftest.oo
-  eval $as_lineno_stack; test "x$as_lineno_stack" = x && { as_lineno=; unset as_lineno;}
+  eval $as_lineno_stack; ${as_lineno_stack:+:} unset as_lineno
   as_fn_set_status $ac_retval
 
 } # ac_fn_c_try_link
@@ -1589,8 +1597,8 @@ cat >config.log <<_ACEOF
 This file contains any messages produced by compilers while
 running configure, to aid debugging if configure makes a mistake.
 
-It was created by RT $as_me rt-4.0.6, which was
-generated by GNU Autoconf 2.67.  Invocation command line was
+It was created by RT $as_me rt-4.0.7, which was
+generated by GNU Autoconf 2.68.  Invocation command line was
 
   $ $0 $@
 
@@ -1848,7 +1856,7 @@ $as_echo "$as_me: loading site script $ac_site_file" >&6;}
       || { { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
 $as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
 as_fn_error $? "failed to load site script $ac_site_file
-See \`config.log' for more details" "$LINENO" 5 ; }
+See \`config.log' for more details" "$LINENO" 5; }
   fi
 done
 
@@ -1946,7 +1954,7 @@ rt_version_major=4
 
 rt_version_minor=0
 
-rt_version_patch=6
+rt_version_patch=7
 
 test "x$rt_version_major" = 'x' && rt_version_major=0
 test "x$rt_version_minor" = 'x' && rt_version_minor=0
@@ -1998,7 +2006,7 @@ ac_configure="$SHELL $ac_aux_dir/configure"  # Please don't use this var.
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for a BSD-compatible install" >&5
 $as_echo_n "checking for a BSD-compatible install... " >&6; }
 if test -z "$INSTALL"; then
-if test "${ac_cv_path_install+set}" = set; then :
+if ${ac_cv_path_install+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   as_save_IFS=$IFS; IFS=$PATH_SEPARATOR
@@ -2079,7 +2087,7 @@ test -z "$INSTALL_DATA" && INSTALL_DATA='${INSTALL} -m 644'
 set dummy perl; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_path_PERL+set}" = set; then :
+if ${ac_cv_path_PERL+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   case $PERL in
@@ -2800,7 +2808,7 @@ if test -n "$ac_tool_prefix"; then
 set dummy ${ac_tool_prefix}gcc; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_CC+set}" = set; then :
+if ${ac_cv_prog_CC+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$CC"; then
@@ -2840,7 +2848,7 @@ if test -z "$ac_cv_prog_CC"; then
 set dummy gcc; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_ac_ct_CC+set}" = set; then :
+if ${ac_cv_prog_ac_ct_CC+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$ac_ct_CC"; then
@@ -2893,7 +2901,7 @@ if test -z "$CC"; then
 set dummy ${ac_tool_prefix}cc; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_CC+set}" = set; then :
+if ${ac_cv_prog_CC+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$CC"; then
@@ -2933,7 +2941,7 @@ if test -z "$CC"; then
 set dummy cc; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_CC+set}" = set; then :
+if ${ac_cv_prog_CC+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$CC"; then
@@ -2992,7 +3000,7 @@ if test -z "$CC"; then
 set dummy $ac_tool_prefix$ac_prog; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_CC+set}" = set; then :
+if ${ac_cv_prog_CC+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$CC"; then
@@ -3036,7 +3044,7 @@ do
 set dummy $ac_prog; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_ac_ct_CC+set}" = set; then :
+if ${ac_cv_prog_ac_ct_CC+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$ac_ct_CC"; then
@@ -3091,7 +3099,7 @@ fi
 test -z "$CC" && { { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
 $as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
 as_fn_error $? "no acceptable C compiler found in \$PATH
-See \`config.log' for more details" "$LINENO" 5 ; }
+See \`config.log' for more details" "$LINENO" 5; }
 
 # Provide some information about the compiler.
 $as_echo "$as_me:${as_lineno-$LINENO}: checking for C compiler version" >&5
@@ -3206,7 +3214,7 @@ sed 's/^/| /' conftest.$ac_ext >&5
 { { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
 $as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
 as_fn_error 77 "C compiler cannot create executables
-See \`config.log' for more details" "$LINENO" 5 ; }
+See \`config.log' for more details" "$LINENO" 5; }
 else
   { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
 $as_echo "yes" >&6; }
@@ -3249,7 +3257,7 @@ else
   { { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
 $as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
 as_fn_error $? "cannot compute suffix of executables: cannot compile and link
-See \`config.log' for more details" "$LINENO" 5 ; }
+See \`config.log' for more details" "$LINENO" 5; }
 fi
 rm -f conftest conftest$ac_cv_exeext
 { $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_exeext" >&5
@@ -3308,7 +3316,7 @@ $as_echo "$ac_try_echo"; } >&5
 $as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
 as_fn_error $? "cannot run C compiled programs.
 If you meant to cross compile, use \`--host'.
-See \`config.log' for more details" "$LINENO" 5 ; }
+See \`config.log' for more details" "$LINENO" 5; }
     fi
   fi
 fi
@@ -3319,7 +3327,7 @@ rm -f conftest.$ac_ext conftest$ac_cv_exeext conftest.out
 ac_clean_files=$ac_clean_files_save
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for suffix of object files" >&5
 $as_echo_n "checking for suffix of object files... " >&6; }
-if test "${ac_cv_objext+set}" = set; then :
+if ${ac_cv_objext+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   cat confdefs.h - <<_ACEOF >conftest.$ac_ext
@@ -3360,7 +3368,7 @@ sed 's/^/| /' conftest.$ac_ext >&5
 { { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
 $as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
 as_fn_error $? "cannot compute suffix of object files: cannot compile
-See \`config.log' for more details" "$LINENO" 5 ; }
+See \`config.log' for more details" "$LINENO" 5; }
 fi
 rm -f conftest.$ac_cv_objext conftest.$ac_ext
 fi
@@ -3370,7 +3378,7 @@ OBJEXT=$ac_cv_objext
 ac_objext=$OBJEXT
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking whether we are using the GNU C compiler" >&5
 $as_echo_n "checking whether we are using the GNU C compiler... " >&6; }
-if test "${ac_cv_c_compiler_gnu+set}" = set; then :
+if ${ac_cv_c_compiler_gnu+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   cat confdefs.h - <<_ACEOF >conftest.$ac_ext
@@ -3407,7 +3415,7 @@ ac_test_CFLAGS=${CFLAGS+set}
 ac_save_CFLAGS=$CFLAGS
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking whether $CC accepts -g" >&5
 $as_echo_n "checking whether $CC accepts -g... " >&6; }
-if test "${ac_cv_prog_cc_g+set}" = set; then :
+if ${ac_cv_prog_cc_g+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   ac_save_c_werror_flag=$ac_c_werror_flag
@@ -3485,7 +3493,7 @@ else
 fi
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $CC option to accept ISO C89" >&5
 $as_echo_n "checking for $CC option to accept ISO C89... " >&6; }
-if test "${ac_cv_prog_cc_c89+set}" = set; then :
+if ${ac_cv_prog_cc_c89+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   ac_cv_prog_cc_c89=no
@@ -3583,7 +3591,7 @@ ac_compiler_gnu=$ac_cv_c_compiler_gnu
 
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for aginitlib in -lgraph" >&5
 $as_echo_n "checking for aginitlib in -lgraph... " >&6; }
-if test "${ac_cv_lib_graph_aginitlib+set}" = set; then :
+if ${ac_cv_lib_graph_aginitlib+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   ac_check_lib_save_LIBS=$LIBS
@@ -3617,7 +3625,7 @@ LIBS=$ac_check_lib_save_LIBS
 fi
 { $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_graph_aginitlib" >&5
 $as_echo "$ac_cv_lib_graph_aginitlib" >&6; }
-if test "x$ac_cv_lib_graph_aginitlib" = x""yes; then :
+if test "x$ac_cv_lib_graph_aginitlib" = xyes; then :
   RT_GRAPHVIZ="1"
 fi
 
@@ -3643,7 +3651,7 @@ fi
 set dummy gdlib-config; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_RT_GD+set}" = set; then :
+if ${ac_cv_prog_RT_GD+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$RT_GD"; then
@@ -3699,7 +3707,7 @@ fi
 set dummy gpg; ac_word=$2
 { $as_echo "$as_me:${as_lineno-$LINENO}: checking for $ac_word" >&5
 $as_echo_n "checking for $ac_word... " >&6; }
-if test "${ac_cv_prog_RT_GPG+set}" = set; then :
+if ${ac_cv_prog_RT_GPG+:} false; then :
   $as_echo_n "(cached) " >&6
 else
   if test -n "$RT_GPG"; then
@@ -3984,10 +3992,21 @@ $as_echo "$as_me: WARNING: cache variable $ac_var contains a newline" >&2;} ;;
      :end' >>confcache
 if diff "$cache_file" confcache >/dev/null 2>&1; then :; else
   if test -w "$cache_file"; then
-    test "x$cache_file" != "x/dev/null" &&
+    if test "x$cache_file" != "x/dev/null"; then
       { $as_echo "$as_me:${as_lineno-$LINENO}: updating cache $cache_file" >&5
 $as_echo "$as_me: updating cache $cache_file" >&6;}
-    cat confcache >$cache_file
+      if test ! -f "$cache_file" || test -h "$cache_file"; then
+       cat confcache >"$cache_file"
+      else
+        case $cache_file in #(
+        */* | ?:*)
+         mv -f confcache "$cache_file"$$ &&
+         mv -f "$cache_file"$$ "$cache_file" ;; #(
+        *)
+         mv -f confcache "$cache_file" ;;
+       esac
+      fi
+    fi
   else
     { $as_echo "$as_me:${as_lineno-$LINENO}: not updating unwritable cache $cache_file" >&5
 $as_echo "$as_me: not updating unwritable cache $cache_file" >&6;}
@@ -4055,7 +4074,7 @@ LTLIBOBJS=$ac_ltlibobjs
 
 
 
-: ${CONFIG_STATUS=./config.status}
+: "${CONFIG_STATUS=./config.status}"
 ac_write_fail=0
 ac_clean_files_save=$ac_clean_files
 ac_clean_files="$ac_clean_files $CONFIG_STATUS"
@@ -4156,6 +4175,7 @@ fi
 IFS=" ""       $as_nl"
 
 # Find who we are.  Look in the path if we contain no directory separator.
+as_myself=
 case $0 in #((
   *[\\/]* ) as_myself=$0 ;;
   *) as_save_IFS=$IFS; IFS=$PATH_SEPARATOR
@@ -4462,8 +4482,8 @@ cat >>$CONFIG_STATUS <<\_ACEOF || ac_write_fail=1
 # report actual input values of CONFIG_FILES etc. instead of their
 # values after options handling.
 ac_log="
-This file was extended by RT $as_me rt-4.0.6, which was
-generated by GNU Autoconf 2.67.  Invocation command line was
+This file was extended by RT $as_me rt-4.0.7, which was
+generated by GNU Autoconf 2.68.  Invocation command line was
 
   CONFIG_FILES    = $CONFIG_FILES
   CONFIG_HEADERS  = $CONFIG_HEADERS
@@ -4515,8 +4535,8 @@ _ACEOF
 cat >>$CONFIG_STATUS <<_ACEOF || ac_write_fail=1
 ac_cs_config="`$as_echo "$ac_configure_args" | sed 's/^ //; s/[\\""\`\$]/\\\\&/g'`"
 ac_cs_version="\\
-RT config.status rt-4.0.6
-configured by $0, generated by GNU Autoconf 2.67,
+RT config.status rt-4.0.7
+configured by $0, generated by GNU Autoconf 2.68,
   with options \\"\$ac_cs_config\\"
 
 Copyright (C) 2010 Free Software Foundation, Inc.
@@ -4658,7 +4678,7 @@ do
     "t/data/configs/apache2.2+mod_perl.conf") CONFIG_FILES="$CONFIG_FILES t/data/configs/apache2.2+mod_perl.conf" ;;
     "t/data/configs/apache2.2+fastcgi.conf") CONFIG_FILES="$CONFIG_FILES t/data/configs/apache2.2+fastcgi.conf" ;;
 
-  *) as_fn_error $? "invalid argument: \`$ac_config_target'" "$LINENO" 5 ;;
+  *) as_fn_error $? "invalid argument: \`$ac_config_target'" "$LINENO" 5;;
   esac
 done
 
@@ -4679,9 +4699,10 @@ fi
 # after its creation but before its name has been assigned to `$tmp'.
 $debug ||
 {
-  tmp=
+  tmp= ac_tmp=
   trap 'exit_status=$?
-  { test -z "$tmp" || test ! -d "$tmp" || rm -fr "$tmp"; } && exit $exit_status
+  : "${ac_tmp:=$tmp}"
+  { test ! -d "$ac_tmp" || rm -fr "$ac_tmp"; } && exit $exit_status
 ' 0
   trap 'as_fn_exit 1' 1 2 13 15
 }
@@ -4689,12 +4710,13 @@ $debug ||
 
 {
   tmp=`(umask 077 && mktemp -d "./confXXXXXX") 2>/dev/null` &&
-  test -n "$tmp" && test -d "$tmp"
+  test -d "$tmp"
 }  ||
 {
   tmp=./conf$$-$RANDOM
   (umask 077 && mkdir "$tmp")
 } || as_fn_error $? "cannot create a temporary directory in ." "$LINENO" 5
+ac_tmp=$tmp
 
 # Set up the scripts for CONFIG_FILES section.
 # No need to generate them if there are no CONFIG_FILES.
@@ -4716,7 +4738,7 @@ else
   ac_cs_awk_cr=$ac_cr
 fi
 
-echo 'BEGIN {' >"$tmp/subs1.awk" &&
+echo 'BEGIN {' >"$ac_tmp/subs1.awk" &&
 _ACEOF
 
 
@@ -4744,7 +4766,7 @@ done
 rm -f conf$$subs.sh
 
 cat >>$CONFIG_STATUS <<_ACEOF || ac_write_fail=1
-cat >>"\$tmp/subs1.awk" <<\\_ACAWK &&
+cat >>"\$ac_tmp/subs1.awk" <<\\_ACAWK &&
 _ACEOF
 sed -n '
 h
@@ -4792,7 +4814,7 @@ t delim
 rm -f conf$$subs.awk
 cat >>$CONFIG_STATUS <<_ACEOF || ac_write_fail=1
 _ACAWK
-cat >>"\$tmp/subs1.awk" <<_ACAWK &&
+cat >>"\$ac_tmp/subs1.awk" <<_ACAWK &&
   for (key in S) S_is_set[key] = 1
   FS = "\a"
 
@@ -4824,7 +4846,7 @@ if sed "s/$ac_cr//" < /dev/null > /dev/null 2>&1; then
   sed "s/$ac_cr\$//; s/$ac_cr/$ac_cs_awk_cr/g"
 else
   cat
-fi < "$tmp/subs1.awk" > "$tmp/subs.awk" \
+fi < "$ac_tmp/subs1.awk" > "$ac_tmp/subs.awk" \
   || as_fn_error $? "could not setup config files machinery" "$LINENO" 5
 _ACEOF
 
@@ -4864,7 +4886,7 @@ do
   esac
   case $ac_mode$ac_tag in
   :[FHL]*:*);;
-  :L* | :C*:*) as_fn_error $? "invalid tag \`$ac_tag'" "$LINENO" 5 ;;
+  :L* | :C*:*) as_fn_error $? "invalid tag \`$ac_tag'" "$LINENO" 5;;
   :[FH]-) ac_tag=-:-;;
   :[FH]*) ac_tag=$ac_tag:$ac_tag.in;;
   esac
@@ -4883,7 +4905,7 @@ do
     for ac_f
     do
       case $ac_f in
-      -) ac_f="$tmp/stdin";;
+      -) ac_f="$ac_tmp/stdin";;
       *) # Look for the file first in the build tree, then in the source tree
         # (if the path is not absolute).  The absolute path cannot be DOS-style,
         # because $ac_f cannot contain `:'.
@@ -4892,7 +4914,7 @@ do
           [\\/$]*) false;;
           *) test -f "$srcdir/$ac_f" && ac_f="$srcdir/$ac_f";;
           esac ||
-          as_fn_error 1 "cannot find input file: \`$ac_f'" "$LINENO" 5 ;;
+          as_fn_error 1 "cannot find input file: \`$ac_f'" "$LINENO" 5;;
       esac
       case $ac_f in *\'*) ac_f=`$as_echo "$ac_f" | sed "s/'/'\\\\\\\\''/g"`;; esac
       as_fn_append ac_file_inputs " '$ac_f'"
@@ -4918,8 +4940,8 @@ $as_echo "$as_me: creating $ac_file" >&6;}
     esac
 
     case $ac_tag in
-    *:-:* | *:-) cat >"$tmp/stdin" \
-      || as_fn_error $? "could not create $ac_file" "$LINENO" 5  ;;
+    *:-:* | *:-) cat >"$ac_tmp/stdin" \
+      || as_fn_error $? "could not create $ac_file" "$LINENO" 5 ;;
     esac
     ;;
   esac
@@ -5049,21 +5071,22 @@ s&@abs_top_builddir@&$ac_abs_top_builddir&;t t
 s&@INSTALL@&$ac_INSTALL&;t t
 $ac_datarootdir_hack
 "
-eval sed \"\$ac_sed_extra\" "$ac_file_inputs" | $AWK -f "$tmp/subs.awk" >$tmp/out \
-  || as_fn_error $? "could not create $ac_file" "$LINENO" 5
+eval sed \"\$ac_sed_extra\" "$ac_file_inputs" | $AWK -f "$ac_tmp/subs.awk" \
+  >$ac_tmp/out || as_fn_error $? "could not create $ac_file" "$LINENO" 5
 
 test -z "$ac_datarootdir_hack$ac_datarootdir_seen" &&
-  { ac_out=`sed -n '/\${datarootdir}/p' "$tmp/out"`; test -n "$ac_out"; } &&
-  { ac_out=`sed -n '/^[         ]*datarootdir[  ]*:*=/p' "$tmp/out"`; test -z "$ac_out"; } &&
+  { ac_out=`sed -n '/\${datarootdir}/p' "$ac_tmp/out"`; test -n "$ac_out"; } &&
+  { ac_out=`sed -n '/^[         ]*datarootdir[  ]*:*=/p' \
+      "$ac_tmp/out"`; test -z "$ac_out"; } &&
   { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: $ac_file contains a reference to the variable \`datarootdir'
 which seems to be undefined.  Please make sure it is defined" >&5
 $as_echo "$as_me: WARNING: $ac_file contains a reference to the variable \`datarootdir'
 which seems to be undefined.  Please make sure it is defined" >&2;}
 
-  rm -f "$tmp/stdin"
+  rm -f "$ac_tmp/stdin"
   case $ac_file in
-  -) cat "$tmp/out" && rm -f "$tmp/out";;
-  *) rm -f "$ac_file" && mv "$tmp/out" "$ac_file";;
+  -) cat "$ac_tmp/out" && rm -f "$ac_tmp/out";;
+  *) rm -f "$ac_file" && mv "$ac_tmp/out" "$ac_file";;
   esac \
   || as_fn_error $? "could not create $ac_file" "$LINENO" 5
  ;;
index 4c3f73f..5d2cd4c 100644 (file)
@@ -23,7 +23,7 @@ to use L<Starman>, a high performance preforking server:
     /opt/rt4/sbin/rt-server --server Starman --port 8080
 
 B<NOTICE>: After you run the standalone server as root, you will need to
-remove your C<var/mason> directory, or the non-standalone servers
+remove your C<var/mason_data> directory, or the non-standalone servers
 (Apache, etc), which run as a non-privileged user, will not be able to
 write to it and will not work.
 
index bd48b6e..1691820 100644 (file)
@@ -640,6 +640,9 @@ Set($NotifyActor, 0);
 By default, RT records each message it sends out to its own internal
 database.  To change this behavior, set C<$RecordOutgoingEmail> to 0
 
+If this is disabled, users' digest mail delivery preferences
+(i.e. EmailFrequency) will also be ignored.
+
 =cut
 
 Set($RecordOutgoingEmail, 1);
@@ -897,8 +900,8 @@ Set(@JSFiles, qw/
     jquery-1.4.2.min.js
     jquery_noconflict.js
     jquery-ui-1.8.4.custom.min.js
+    jquery-ui-timepicker-addon.js
     jquery-ui-patch-datepicker.js
-    ui.timepickr.js
     titlebox-state.js
     util.js
     userautocomplete.js
@@ -1826,6 +1829,16 @@ If the "RT has detected a possible cross-site request forgery" error is triggere
 by a host:port sent by your browser that you believe should be valid, you can copy
 the host:port from the error message into this list.
 
+Simple wildcards, similar to SSL certificates, are allowed.  For example:
+
+    *.example.com:80    # matches foo.example.com
+                        # but not example.com
+                        #      or foo.bar.example.com
+
+    www*.example.com:80 # matches www3.example.com
+                        #     and www-test.example.com
+                        #     and www.example.com
+
 =cut
 
 Set(@ReferrerWhitelist, qw());
@@ -2279,10 +2292,11 @@ all possible transitions in each lifecycle using the following format:
 
 =head3 Statuses available during ticket creation
 
-By default users can create tickets with any status, except
-deleted. If you want to restrict statuses available during creation
-then describe transition from '' (empty string), like in the example
-above.
+By default users can create tickets with a status of new,
+open, or resolved, but cannot create tickets with a status of
+rejected, stalled, or deleted. If you want to change the statuses
+available during creation, update the transition from '' (empty
+string), like in the example above.
 
 =head3 Protecting status changes with rights
 
index 29a7d02..4a397fa 100644 (file)
@@ -49,7 +49,7 @@ Set($MessageBoxWidth, 80);
 Set($MessageBoxRichTextHeight, 368);
 
 #redirects to ticket display on quick create
-#Set($QuickCreateRedirect, 1);
+#Set($DisplayTicketAfterQuickCreate, 1);
 
 #Set(@Plugins,(qw(Extension::QuickDelete RT::FM)));
 
index cc07cec..7ab746d 100644 (file)
@@ -1,4 +1,4 @@
-# Initial data for a fresh RT3 Installation.
+# Initial data for a fresh RT installation.
 
 @Users = (
     {  Name         => 'root',
index 138971c..6897be2 100644 (file)
@@ -3,7 +3,7 @@
 CREATE TABLE Attachments (
   id INTEGER PRIMARY KEY  ,
   TransactionId INTEGER  ,
-  Parent integer NULL  ,
+  Parent integer NULL DEFAULT 0 ,
   MessageId varchar(160) NULL  ,
   Subject varchar(255) NULL  ,
   Filename varchar(255) NULL  ,
@@ -11,7 +11,7 @@ CREATE TABLE Attachments (
   ContentEncoding varchar(80) NULL  ,
   Content LONGTEXT NULL  ,
   Headers LONGTEXT NULL  ,
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL 
   
 ) ;
@@ -30,12 +30,12 @@ CREATE TABLE Queues (
   CommentAddress varchar(120) NULL  ,
   Lifecycle varchar(32) NULL  ,
   SubjectTag varchar(120) NULL  ,
-  InitialPriority integer NULL  ,
-  FinalPriority integer NULL  ,
-  DefaultDueIn integer NULL  ,
-  Creator integer NULL  ,
+  InitialPriority integer NULL DEFAULT 0 ,
+  FinalPriority integer NULL DEFAULT 0 ,
+  DefaultDueIn integer NULL DEFAULT 0 ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  ,
   Disabled int2 NOT NULL DEFAULT 0 
  
@@ -51,11 +51,11 @@ CREATE TABLE Links (
   Base varchar(240) NULL  ,
   Target varchar(240) NULL  ,
   Type varchar(20) NOT NULL  ,
-  LocalTarget integer NULL  ,
-  LocalBase integer NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LocalTarget integer NULL DEFAULT 0 ,
+  LocalBase integer NULL DEFAULT 0 ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  ,
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  
   
 ) ;
@@ -106,9 +106,9 @@ CREATE TABLE ScripConditions (
   Argument varchar(255) NULL  ,
   ApplicableTransTypes varchar(60) NULL  ,
 
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  
   
 ) ;
@@ -119,8 +119,8 @@ CREATE TABLE ScripConditions (
 CREATE TABLE Transactions (
   id INTEGER PRIMARY KEY  ,
   ObjectType varchar(255) NULL  ,
-  ObjectId integer NULL  ,
-  TimeTaken integer NULL  ,
+  ObjectId integer NULL DEFAULT 0 ,
+  TimeTaken integer NULL DEFAULT 0 ,
   Type varchar(20) NULL  ,
   Field varchar(40) NULL  ,
   OldValue varchar(255) NULL  ,
@@ -130,7 +130,7 @@ CREATE TABLE Transactions (
   NewReference integer NULL  ,
   Data varchar(255) NULL  ,
 
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  
   
 ) ;
@@ -143,19 +143,19 @@ CREATE INDEX Transactions1 ON Transactions (ObjectType, ObjectId);
 CREATE TABLE Scrips (
   id INTEGER PRIMARY KEY  ,
   Description varchar(255),
-  ScripCondition integer NULL  ,
-  ScripAction integer NULL  ,
+  ScripCondition integer NULL DEFAULT 0 ,
+  ScripAction integer NULL DEFAULT 0 ,
   ConditionRules text NULL  ,
   ActionRules text NULL  ,
   CustomIsApplicableCode text NULL  ,
   CustomPrepareCode text NULL  ,
   CustomCommitCode text NULL  ,
   Stage varchar(32) NULL  ,
-  Queue integer NULL  ,
-  Template integer NULL  ,
-  Creator integer NULL  ,
+  Queue integer NULL DEFAULT 0 ,
+  Template integer NULL DEFAULT 0 ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  
   
 ) ;
@@ -167,7 +167,7 @@ CREATE TABLE ACL (
   id INTEGER PRIMARY KEY  ,
   PrincipalType varchar(25) NOT NULL,
 
-  PrincipalId INTEGER,
+  PrincipalId INTEGER DEFAULT 0,
   RightName varchar(25) NOT NULL  ,
   ObjectType varchar(25) NOT NULL  ,
   ObjectId INTEGER default 0,
@@ -185,8 +185,8 @@ CREATE TABLE ACL (
 
 CREATE TABLE GroupMembers (
   id INTEGER PRIMARY KEY  ,
-  GroupId integer NULL,
-  MemberId integer NULL,
+  GroupId integer NULL DEFAULT 0,
+  MemberId integer NULL DEFAULT 0,
   Creator integer NOT NULL DEFAULT 0  ,
   Created DATETIME NULL  ,
   LastUpdatedBy integer NOT NULL DEFAULT 0  ,
@@ -250,9 +250,9 @@ CREATE TABLE Users (
   Timezone char(50) NULL  ,
   PGPKey text NULL,
 
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  
   
 ) ;
@@ -270,20 +270,20 @@ CREATE INDEX Users4 ON Users (EmailAddress);
 
 CREATE TABLE Tickets (
   id INTEGER PRIMARY KEY  ,
-  EffectiveId integer NULL  ,
-  Queue integer NULL  ,
+  EffectiveId integer NULL DEFAULT 0 ,
+  Queue integer NULL DEFAULT 0 ,
   Type varchar(16) NULL  ,
-  IssueStatement integer NULL  ,
-  Resolution integer NULL  ,
-  Owner integer NULL  ,
+  IssueStatement integer NULL DEFAULT 0 ,
+  Resolution integer NULL DEFAULT 0 ,
+  Owner integer NULL DEFAULT 0 ,
   Subject varchar(200) NULL DEFAULT '[no subject]' ,
-  InitialPriority integer NULL  ,
-  FinalPriority integer NULL  ,
-  Priority integer NULL  ,
-  TimeEstimated integer NULL  ,
-  TimeWorked integer NULL  ,
+  InitialPriority integer NULL DEFAULT 0 ,
+  FinalPriority integer NULL DEFAULt 0 ,
+  Priority integer NULL DEFAULT 0 ,
+  TimeEstimated integer NULL DEFAULT 0 ,
+  TimeWorked integer NULL DEFAULT 0 ,
   Status varchar(64) NULL  ,
-  TimeLeft integer NULL  ,
+  TimeLeft integer NULL DEFAULT 0 ,
   Told DATETIME NULL  ,
   Starts DATETIME NULL  ,
   Started DATETIME NULL  ,
@@ -291,9 +291,9 @@ CREATE TABLE Tickets (
   Resolved DATETIME NULL  ,
 
 
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  ,
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
   Disabled int2 NOT NULL DEFAULT 0
   
@@ -315,9 +315,9 @@ CREATE TABLE ScripActions (
   Description varchar(255) NULL  ,
   ExecModule varchar(60) NULL  ,
   Argument varchar(255) NULL  ,
-  Creator integer NULL  ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  
   
 ) ;
@@ -333,11 +333,11 @@ CREATE TABLE Templates (
   Description varchar(255) NULL  ,
   Type varchar(16) NULL  ,
   Language varchar(16) NULL  ,
-  TranslationOf integer NULL  ,
+  TranslationOf integer NULL DEFAULT 0 ,
   Content blob NULL  ,
   LastUpdated DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
-  Creator integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  
   
 ) ;
@@ -437,10 +437,10 @@ CREATE TABLE Attributes (
   Content LONGTEXT NULL  ,
   ContentType varchar(16),
   ObjectType varchar(25) NOT NULL  ,
-  ObjectId INTEGER default 0,
-  Creator integer NULL  ,
+  ObjectId INTEGER ,
+  Creator integer NULL DEFAULT 0 ,
   Created DATETIME NULL  ,
-  LastUpdatedBy integer NULL  ,
+  LastUpdatedBy integer NULL DEFAULT 0 ,
   LastUpdated DATETIME NULL  
  
 ) ;
@@ -483,22 +483,22 @@ Parent integer NOT NULL DEFAULT 0,
 Name varchar(255) NOT NULL DEFAULT '',
 Description varchar(255) NOT NULL DEFAULT '',
 ObjectType varchar(64) NOT NULL DEFAULT '',
-ObjectId integer NOT NULL
+ObjectId integer NOT NULL DEFAULT 0
 );
 
 
 CREATE TABLE ObjectTopics (
 id INTEGER PRIMARY KEY,
-Topic integer NOT NULL,
+Topic integer NOT NULL DEFAULT 0,
 ObjectType varchar(64) NOT NULL DEFAULT '',
-ObjectId integer NOT NULL
+ObjectId integer NOT NULL DEFAULT 0
 );
 
 CREATE TABLE ObjectClasses (
 id INTEGER PRIMARY KEY,
-Class integer NOT NULL,
+Class integer NOT NULL DEFAULT 0,
 ObjectType varchar(64) NOT NULL DEFAULT '',
-ObjectId integer NOT NULL,
+ObjectId integer NOT NULL DEFAULT 0,
 Creator integer NOT NULL DEFAULT 0,
 Created TIMESTAMP NULL,
 LastUpdatedBy integer NOT NULL DEFAULT 0,
index f699836..d8b0499 100644 (file)
@@ -1,37 +1,32 @@
-alter Table Transactions ADD Column (ObjectType varchar(64) not null);
-update Transactions set ObjectType = 'RT::Ticket';
-alter table Transactions drop column EffectiveTicket;
-alter table Transactions add column ReferenceType varchar(255) NULL;
-alter table Transactions add column OldReference integer NULL;      
-alter table Transactions add column NewReference integer NULL;
-alter table Transactions drop index transactions1;            
-alter table Transactions change Ticket ObjectId integer NOT NULL DEFAULT 0  ;
+drop index transactions1 ON Transactions;
 
-CREATE INDEX Transactions1 ON Transactions (ObjectType, ObjectId);
-
-alter table TicketCustomFieldValues rename ObjectCustomFieldValues;
+alter Table Transactions
+    ADD COLUMN  (ObjectType varchar(64) not null),
+    DROP COLUMN EffectiveTicket,
+    ADD COLUMN  ReferenceType varchar(255) NULL,
+    ADD COLUMN  OldReference integer NULL,
+    ADD COLUMN  NewReference integer NULL,
+    CHANGE      Ticket ObjectId integer NOT NULL DEFAULT 0;
 
-alter table ObjectCustomFieldValues  change Ticket ObjectId integer NOT NULL DEFAULT 0  ;
+UPDATE Transactions set ObjectType = 'RT::Ticket';
+CREATE INDEX Transactions1 ON Transactions (ObjectType, ObjectId);
 
-alter table ObjectCustomFieldValues add column ObjectType varchar(255) not null;
+alter table TicketCustomFieldValues rename ObjectCustomFieldValues,
+    change Ticket ObjectId integer NOT NULL DEFAULT 0  ,
+    add column ObjectType varchar(255) not null,
+    add column Current bool default 1,
+    add column LargeContent LONGTEXT NULL,
+    add column ContentType varchar(80) NULL,
+    add column ContentEncoding varchar(80) NULL;
 
 update ObjectCustomFieldValues set ObjectType = 'RT::Ticket';
 
-alter table ObjectCustomFieldValues add column Current bool default 1;  
-
-alter table ObjectCustomFieldValues add column LargeContent LONGTEXT NULL;
-
-alter table ObjectCustomFieldValues add column ContentType varchar(80) NULL;
-
-alter table ObjectCustomFieldValues add column ContentEncoding varchar(80) NULL;
-
 # These could fail if there's no such index and there's no "drop index if exists" syntax
 #alter table ObjectCustomFieldValues drop index ticketcustomfieldvalues1;
 #alter table ObjectCustomFieldValues drop index ticketcustomfieldvalues2;
 
-alter table ObjectCustomFieldValues add index ObjectCustomFieldValues1 (Content); 
-
-alter table ObjectCustomFieldValues add index ObjectCustomFieldValues2 (CustomField,ObjectType,ObjectId); 
+alter table ObjectCustomFieldValues add index ObjectCustomFieldValues1 (Content),
+    add index ObjectCustomFieldValues2 (CustomField,ObjectType,ObjectId);
 
 
 CREATE TABLE ObjectCustomFields (
@@ -50,10 +45,10 @@ CREATE TABLE ObjectCustomFields (
 
 INSERT into ObjectCustomFields (id, CustomField, ObjectId, SortOrder, Creator, LastUpdatedBy) SELECT  null, id, Queue, SortOrder, Creator, LastUpdatedBy from CustomFields;
 
-alter table CustomFields add column LookupType varchar(255) NOT NULL;
-alter table CustomFields add column Repeated int2 NOT NULL DEFAULT 0 ;
-alter table CustomFields add column Pattern varchar(255) NULL;
-alter table CustomFields add column MaxValues integer;
+alter table CustomFields add column LookupType varchar(255) NOT NULL,
+    add column Repeated int2 NOT NULL DEFAULT 0 ,
+    add column Pattern varchar(255) NULL,
+    add column MaxValues integer;
 # See above
 # alter table CustomFields drop index CustomFields1;
 
@@ -62,4 +57,4 @@ UPDATE CustomFields SET MaxValues = 1 WHERE Type LIKE '%Single';
 UPDATE CustomFields SET Type = 'Select' WHERE Type LIKE 'Select%';
 UPDATE CustomFields SET Type = 'Freeform' WHERE Type LIKE 'Freeform%';
 UPDATE CustomFields Set LookupType = 'RT::Queue-RT::Ticket';
-alter table CustomFields drop column Queue; 
+alter table CustomFields drop column Queue;
index cc35d40..eff8478 100644 (file)
@@ -1,5 +1,5 @@
-ALTER TABLE ObjectCustomFieldValues ADD COLUMN SortOrder INTEGER NOT NULL DEFAULT 0;
-ALTER TABLE ObjectCustomFieldValues ADD COLUMN Disabled int2 NOT NULL DEFAULT 0;
+ALTER TABLE ObjectCustomFieldValues ADD COLUMN SortOrder INTEGER NOT NULL DEFAULT 0,
+                                    ADD COLUMN Disabled int2 NOT NULL DEFAULT 0;
 
 UPDATE ObjectCustomFieldValues SET Disabled = 1 WHERE Current = 0;
 ALTER TABLE ObjectCustomFieldValues DROP COLUMN Current;
index 4bd0907..fe5018c 100644 (file)
@@ -6,15 +6,15 @@ AND CustomFieldValues.id = Attributes.ObjectId);
 
 DELETE FROM Attributes WHERE Name = 'Category' AND ObjectType = 'RT::CustomFieldValue';
 
-ALTER TABLE Groups ADD COLUMN Creator integer NOT NULL DEFAULT 0;
-ALTER TABLE Groups ADD COLUMN Created DATETIME NULL;
-ALTER TABLE Groups ADD COLUMN LastUpdatedBy integer NOT NULL DEFAULT 0;
-ALTER TABLE Groups ADD COLUMN LastUpdated DATETIME NULL;
-ALTER TABLE GroupMembers ADD COLUMN Creator integer NOT NULL DEFAULT 0;
-ALTER TABLE GroupMembers ADD COLUMN Created DATETIME NULL;
-ALTER TABLE GroupMembers ADD COLUMN LastUpdatedBy integer NOT NULL DEFAULT 0;
-ALTER TABLE GroupMembers ADD COLUMN LastUpdated DATETIME NULL;
-ALTER TABLE ACL ADD COLUMN Creator integer NOT NULL DEFAULT 0;
-ALTER TABLE ACL ADD COLUMN Created DATETIME NULL;
-ALTER TABLE ACL ADD COLUMN LastUpdatedBy integer NOT NULL DEFAULT 0;
-ALTER TABLE ACL ADD COLUMN LastUpdated DATETIME NULL;
+ALTER TABLE Groups ADD COLUMN Creator integer NOT NULL DEFAULT 0,
+    ADD COLUMN Created DATETIME NULL,
+    ADD COLUMN LastUpdatedBy integer NOT NULL DEFAULT 0,
+    ADD COLUMN LastUpdated DATETIME NULL;
+ALTER TABLE GroupMembers ADD COLUMN Creator integer NOT NULL DEFAULT 0,
+    ADD COLUMN Created DATETIME NULL,
+    ADD COLUMN LastUpdatedBy integer NOT NULL DEFAULT 0,
+    ADD COLUMN LastUpdated DATETIME NULL;
+ALTER TABLE ACL ADD COLUMN Creator integer NOT NULL DEFAULT 0,
+    ADD COLUMN Created DATETIME NULL,
+    ADD COLUMN LastUpdatedBy integer NOT NULL DEFAULT 0,
+    ADD COLUMN LastUpdated DATETIME NULL;
index 1be1656..4cbed6c 100644 (file)
@@ -1,6 +1,6 @@
 ALTER TABLE Users ADD COLUMN AuthToken VARCHAR(16) CHARACTER SET ascii NULL;
-ALTER TABLE CustomFields ADD COLUMN BasedOn INTEGER NULL;
-ALTER TABLE CustomFields ADD COLUMN RenderType VARCHAR(64) NULL;
-ALTER TABLE CustomFields ADD COLUMN ValuesClass VARCHAR(64) CHARACTER SET ascii NULL;
-ALTER TABLE Queues ADD COLUMN SubjectTag VARCHAR(120) NULL;
-ALTER TABLE Queues ADD COLUMN Lifecycle VARCHAR(32) NULL;
+ALTER TABLE CustomFields ADD COLUMN BasedOn INTEGER NULL,
+    ADD COLUMN RenderType VARCHAR(64) NULL,
+    ADD COLUMN ValuesClass VARCHAR(64) CHARACTER SET ascii NULL;
+ALTER TABLE Queues ADD COLUMN SubjectTag VARCHAR(120) NULL,
+    ADD COLUMN Lifecycle VARCHAR(32) NULL;
index 063f7f7..4372a56 100644 (file)
@@ -138,6 +138,8 @@ up logging|/InitLogging>, and L<loads plugins|/InitPlugins>.
 
 sub Init {
 
+    my @arg = @_;
+
     CheckPerlRequirements();
 
     InitPluginPaths();
@@ -146,7 +148,7 @@ sub Init {
     ConnectToDatabase();
     InitSystemObjects();
     InitClasses();
-    InitLogging();
+    InitLogging(@arg);
     InitPlugins();
     RT::I18N->Init;
     RT->Config->PostLoadCheck;
@@ -174,6 +176,8 @@ Create the Logger object and set up signal handlers.
 
 sub InitLogging {
 
+    my %arg = @_;
+
     # We have to set the record separator ($, man perlvar)
     # or Log::Dispatch starts getting
     # really pissy, as some other module we use unsets it.
@@ -309,11 +313,14 @@ sub InitLogging {
                          ));
         }
     }
-    InitSignalHandlers();
+    InitSignalHandlers(%arg);
 }
 
 sub InitSignalHandlers {
 
+    my %arg = @_;
+    return if $arg{'NoSignalHandlers'};
+
 # Signal handlers
 ## This is the default handling of warnings and die'ings in the code
 ## (including other used modules - maybe except for errors catched by
diff --git a/rt/lib/RT.pm.in b/rt/lib/RT.pm.in
deleted file mode 100644 (file)
index fafd2b7..0000000
+++ /dev/null
@@ -1,719 +0,0 @@
-# BEGIN BPS TAGGED BLOCK {{{
-#
-# COPYRIGHT:
-#
-# This software is Copyright (c) 1996-2011 Best Practical Solutions, LLC
-#                                          <sales@bestpractical.com>
-#
-# (Except where explicitly superseded by other copyright notices)
-#
-#
-# LICENSE:
-#
-# This work is made available to you under the terms of Version 2 of
-# the GNU General Public License. A copy of that license should have
-# been provided with this software, but in any event can be snarfed
-# from www.gnu.org.
-#
-# This work is distributed in the hope that it will be useful, but
-# WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-# General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program; if not, write to the Free Software
-# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-# 02110-1301 or visit their web page on the internet at
-# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-#
-#
-# CONTRIBUTION SUBMISSION POLICY:
-#
-# (The following paragraph is not intended to limit the rights granted
-# to you to modify and distribute this software under the terms of
-# the GNU General Public License and is only of importance to you if
-# you choose to contribute your changes and enhancements to the
-# community by submitting them to Best Practical Solutions, LLC.)
-#
-# By intentionally submitting any modifications, corrections or
-# derivatives to this work, or any other work intended for use with
-# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-# you are the copyright holder for those contributions and you grant
-# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-# royalty-free, perpetual, license to use, copy, create derivative
-# works based on those contributions, and sublicense and distribute
-# those contributions and any derivatives thereof.
-#
-# END BPS TAGGED BLOCK }}}
-
-use strict;
-use warnings;
-
-package RT;
-
-
-use File::Spec ();
-use Cwd ();
-
-use vars qw($Config $System $SystemUser $Nobody $Handle $Logger $_INSTALL_MODE);
-
-our $VERSION = '@RT_VERSION_MAJOR@.@RT_VERSION_MINOR@.@RT_VERSION_PATCH@';
-
-@DATABASE_ENV_PREF@
-
-our $BasePath = '@RT_PATH@';
-our $EtcPath = '@RT_ETC_PATH@';
-our $BinPath = '@RT_BIN_PATH@';
-our $SbinPath = '@RT_SBIN_PATH@';
-our $VarPath = '@RT_VAR_PATH@';
-our $PluginPath = '@RT_PLUGIN_PATH@';
-our $LocalPath = '@RT_LOCAL_PATH@';
-our $LocalEtcPath = '@LOCAL_ETC_PATH@';
-our $LocalLibPath        =    '@LOCAL_LIB_PATH@';
-our $LocalLexiconPath = '@LOCAL_LEXICON_PATH@';
-our $LocalPluginPath = $LocalPath."/plugins";
-
-
-# $MasonComponentRoot is where your rt instance keeps its mason html files
-
-our $MasonComponentRoot = '@MASON_HTML_PATH@';
-
-# $MasonLocalComponentRoot is where your rt instance keeps its site-local
-# mason html files.
-
-our $MasonLocalComponentRoot = '@MASON_LOCAL_HTML_PATH@';
-
-# $MasonDataDir Where mason keeps its datafiles
-
-our $MasonDataDir = '@MASON_DATA_PATH@';
-
-# RT needs to put session data (for preserving state between connections
-# via the web interface)
-our $MasonSessionDir = '@MASON_SESSION_PATH@';
-
-unless (  File::Spec->file_name_is_absolute($EtcPath) ) {
-
-# if BasePath exists and is absolute, we won't infer it from $INC{'RT.pm'}.
-# otherwise RT.pm will make src dir(where we configure RT) be the BasePath 
-# instead of the --prefix one
-    unless ( -d $BasePath && File::Spec->file_name_is_absolute($BasePath) ) {
-        my $pm_path = ( File::Spec->splitpath( $INC{'RT.pm'} ) )[1];
-
-       # need rel2abs here is to make sure path is absolute, since $INC{'RT.pm'}
-       # is not always absolute
-        $BasePath =
-          File::Spec->rel2abs(
-            File::Spec->catdir( $pm_path, File::Spec->updir ) );
-    }
-
-    $BasePath = Cwd::realpath( $BasePath );
-
-    for my $path ( qw/EtcPath BinPath SbinPath VarPath LocalPath LocalEtcPath
-            LocalLibPath LocalLexiconPath PluginPath LocalPluginPath 
-            MasonComponentRoot MasonLocalComponentRoot MasonDataDir 
-            MasonSessionDir/ ) {
-        no strict 'refs';
-        # just change relative ones
-        $$path = File::Spec->catfile( $BasePath, $$path )
-          unless File::Spec->file_name_is_absolute( $$path );
-    }
-}
-
-
-=head1 NAME
-
-RT - Request Tracker
-
-=head1 SYNOPSIS
-
-A fully featured request tracker package
-
-=head1 DESCRIPTION
-
-=head2 INITIALIZATION
-
-=head2 LoadConfig
-
-Load RT's config file.  First, the site configuration file
-(F<RT_SiteConfig.pm>) is loaded, in order to establish overall site
-settings like hostname and name of RT instance.  Then, the core
-configuration file (F<RT_Config.pm>) is loaded to set fallback values
-for all settings; it bases some values on settings from the site
-configuration file.
-
-In order for the core configuration to not override the site's
-settings, the function C<Set> is used; it only sets values if they
-have not been set already.
-
-=cut
-
-sub LoadConfig {
-    require RT::Config;
-    $Config = new RT::Config;
-    $Config->LoadConfigs;
-    require RT::I18N;
-
-    # RT::Essentials mistakenly recommends that WebPath be set to '/'.
-    # If the user does that, do what they mean.
-    $RT::WebPath = '' if ($RT::WebPath eq '/');
-
-    # fix relative LogDir and GnuPG homedir
-    unless ( File::Spec->file_name_is_absolute( $Config->Get('LogDir') ) ) {
-        $Config->Set( LogDir =>
-              File::Spec->catfile( $BasePath, $Config->Get('LogDir') ) );
-    }
-
-    my $gpgopts = $Config->Get('GnuPGOptions');
-    unless ( File::Spec->file_name_is_absolute( $gpgopts->{homedir} ) ) {
-        $gpgopts->{homedir} = File::Spec->catfile( $BasePath, $gpgopts->{homedir} );
-    }
-    
-    RT::I18N->Init;
-}
-
-=head2 Init
-
-L<Connect to the database /ConnectToDatabase>, L<initilizes system objects /InitSystemObjects>,
-L<preloads classes /InitClasses> and L<set up logging /InitLogging>.
-
-=cut
-
-sub Init {
-
-    my @arg = @_;
-
-    CheckPerlRequirements();
-
-    InitPluginPaths();
-
-    #Get a database connection
-    ConnectToDatabase();
-    InitSystemObjects();
-    InitClasses();
-    InitLogging(@arg); 
-    InitPlugins();
-    RT->Config->PostLoadCheck;
-
-}
-
-=head2 ConnectToDatabase
-
-Get a database connection. See also </Handle>.
-
-=cut
-
-sub ConnectToDatabase {
-    require RT::Handle;
-    $Handle = new RT::Handle unless $Handle;
-    $Handle->Connect;
-    return $Handle;
-}
-
-=head2 InitLogging
-
-Create the Logger object and set up signal handlers.
-
-=cut
-
-sub InitLogging {
-
-    my %arg = @_;
-
-    # We have to set the record separator ($, man perlvar)
-    # or Log::Dispatch starts getting
-    # really pissy, as some other module we use unsets it.
-    $, = '';
-    use Log::Dispatch 1.6;
-
-    my %level_to_num = (
-        map( { $_ => } 0..7 ),
-        debug     => 0,
-        info      => 1,
-        notice    => 2,
-        warning   => 3,
-        error     => 4, 'err' => 4,
-        critical  => 5, crit  => 5,
-        alert     => 6, 
-        emergency => 7, emerg => 7,
-    );
-
-    unless ( $RT::Logger ) {
-
-        $RT::Logger = Log::Dispatch->new;
-
-        my $stack_from_level;
-        if ( $stack_from_level = RT->Config->Get('LogStackTraces') ) {
-            # if option has old style '\d'(true) value
-            $stack_from_level = 0 if $stack_from_level =~ /^\d+$/;
-            $stack_from_level = $level_to_num{ $stack_from_level } || 0;
-        } else {
-            $stack_from_level = 99; # don't log
-        }
-
-        my $simple_cb = sub {
-            # if this code throw any warning we can get segfault
-            no warnings;
-            my %p = @_;
-
-            # skip Log::* stack frames
-            my $frame = 0;
-            $frame++ while caller($frame) && caller($frame) =~ /^Log::/;
-            my ($package, $filename, $line) = caller($frame);
-
-            $p{'message'} =~ s/(?:\r*\n)+$//;
-            return "[". gmtime(time) ."] [". $p{'level'} ."]: "
-                . $p{'message'} ." ($filename:$line)\n";
-        };
-
-        my $syslog_cb = sub {
-            # if this code throw any warning we can get segfault
-            no warnings;
-            my %p = @_;
-
-            my $frame = 0; # stack frame index
-            # skip Log::* stack frames
-            $frame++ while caller($frame) && caller($frame) =~ /^Log::/;
-            my ($package, $filename, $line) = caller($frame);
-
-            # syswrite() cannot take utf8; turn it off here.
-            Encode::_utf8_off($p{message});
-
-            $p{message} =~ s/(?:\r*\n)+$//;
-            if ($p{level} eq 'debug') {
-                return "$p{message}\n";
-            } else {
-                return "$p{message} ($filename:$line)\n";
-            }
-        };
-
-        my $stack_cb = sub {
-            no warnings;
-            my %p = @_;
-            return $p{'message'} unless $level_to_num{ $p{'level'} } >= $stack_from_level;
-            
-            require Devel::StackTrace;
-            my $trace = Devel::StackTrace->new( ignore_class => [ 'Log::Dispatch', 'Log::Dispatch::Base' ] );
-            return $p{'message'} . $trace->as_string;
-
-            # skip calling of the Log::* subroutins
-            my $frame = 0;
-            $frame++ while caller($frame) && caller($frame) =~ /^Log::/;
-            $frame++ while caller($frame) && (caller($frame))[3] =~ /^Log::/;
-
-            $p{'message'} .= "\nStack trace:\n";
-            while( my ($package, $filename, $line, $sub) = caller($frame++) ) {
-                $p{'message'} .= "\t$sub(...) called at $filename:$line\n";
-            }
-            return $p{'message'};
-        };
-
-        if ( $Config->Get('LogToFile') ) {
-            my ($filename, $logdir) = (
-                $Config->Get('LogToFileNamed') || 'rt.log',
-                $Config->Get('LogDir') || File::Spec->catdir( $VarPath, 'log' ),
-            );
-            if ( $filename =~ m![/\\]! ) { # looks like an absolute path.
-                ($logdir) = $filename =~ m{^(.*[/\\])};
-            }
-            else {
-                $filename = File::Spec->catfile( $logdir, $filename );
-            }
-
-            unless ( -d $logdir && ( ( -f $filename && -w $filename ) || -w $logdir ) ) {
-                # localizing here would be hard when we don't have a current user yet
-                die "Log file '$filename' couldn't be written or created.\n RT can't run.";
-            }
-
-            require Log::Dispatch::File;
-            $RT::Logger->add( Log::Dispatch::File->new
-                           ( name=>'file',
-                             min_level=> $Config->Get('LogToFile'),
-                             filename=> $filename,
-                             mode=>'append',
-                             callbacks => [ $simple_cb, $stack_cb ],
-                           ));
-        }
-        if ( $Config->Get('LogToScreen') ) {
-            require Log::Dispatch::Screen;
-            $RT::Logger->add( Log::Dispatch::Screen->new
-                         ( name => 'screen',
-                           min_level => $Config->Get('LogToScreen'),
-                           callbacks => [ $simple_cb, $stack_cb ],
-                           stderr => 1,
-                         ));
-        }
-        if ( $Config->Get('LogToSyslog') ) {
-            require Log::Dispatch::Syslog;
-            $RT::Logger->add(Log::Dispatch::Syslog->new
-                         ( name => 'syslog',
-                           ident => 'RT',
-                           min_level => $Config->Get('LogToSyslog'),
-                           callbacks => [ $syslog_cb, $stack_cb ],
-                           stderr => 1,
-                           $Config->Get('LogToSyslogConf'),
-                         ));
-        }
-    }
-    InitSignalHandlers(%arg);
-}
-
-sub InitSignalHandlers {
-
-    my %arg = @_;
-
-# Signal handlers
-## This is the default handling of warnings and die'ings in the code
-## (including other used modules - maybe except for errors catched by
-## Mason).  It will log all problems through the standard logging
-## mechanism (see above).
-
-    unless ( $arg{'NoSignalHandlers'} ) {
-
-        $SIG{__WARN__} = sub {
-            # The 'wide character' warnings has to be silenced for now, at least
-            # until HTML::Mason offers a sane way to process both raw output and
-            # unicode strings.
-            # use 'goto &foo' syntax to hide ANON sub from stack
-            if( index($_[0], 'Wide character in ') != 0 ) {
-                unshift @_, $RT::Logger, qw(level warning message);
-                goto &Log::Dispatch::log;
-            }
-        };
-
-        #When we call die, trap it and log->crit with the value of the die.
-
-        $SIG{__DIE__}  = sub {
-            # if we are not in eval and perl is not parsing code
-            # then rollback transactions and log RT error
-            unless ($^S || !defined $^S ) {
-                $RT::Handle->Rollback(1) if $RT::Handle;
-                $RT::Logger->crit("$_[0]") if $RT::Logger;
-            }
-            die $_[0];
-        };
-
-    }
-}
-
-
-sub CheckPerlRequirements {
-    if ($^V < 5.008003) {
-        die sprintf "RT requires Perl v5.8.3 or newer.  Your current Perl is v%vd\n", $^V; 
-    }
-
-    # use $error here so the following "die" can still affect the global $@
-    my $error;
-    {
-        local $@;
-        eval {
-            my $x = '';
-            my $y = \$x;
-            require Scalar::Util;
-            Scalar::Util::weaken($y);
-        };
-        $error = $@;
-    }
-
-    if ($error) {
-        die <<"EOF";
-
-RT requires the Scalar::Util module be built with support for  the 'weaken'
-function. 
-
-It is sometimes the case that operating system upgrades will replace 
-a working Scalar::Util with a non-working one. If your system was working
-correctly up until now, this is likely the cause of the problem.
-
-Please reinstall Scalar::Util, being careful to let it build with your C 
-compiler. Ususally this is as simple as running the following command as
-root.
-
-    perl -MCPAN -e'install Scalar::Util'
-
-EOF
-
-    }
-}
-
-=head2 InitClasses
-
-Load all modules that define base classes.
-
-=cut
-
-sub InitClasses {
-    shift if @_%2; # so we can call it as a function or method
-    my %args = (@_);
-    require RT::Tickets;
-    require RT::Transactions;
-    require RT::Attachments;
-    require RT::Users;
-    require RT::Principals;
-    require RT::CurrentUser;
-    require RT::Templates;
-    require RT::Queues;
-    require RT::ScripActions;
-    require RT::ScripConditions;
-    require RT::Scrips;
-    require RT::Groups;
-    require RT::GroupMembers;
-    require RT::CustomFields;
-    require RT::CustomFieldValues;
-    require RT::ObjectCustomFields;
-    require RT::ObjectCustomFieldValues;
-    require RT::Attributes;
-    require RT::Dashboard;
-    require RT::Approval;
-
-    # on a cold server (just after restart) people could have an object
-    # in the session, as we deserialize it so we never call constructor
-    # of the class, so the list of accessible fields is empty and we die
-    # with "Method xxx is not implemented in RT::SomeClass"
-    $_->_BuildTableAttributes foreach qw(
-        RT::Ticket
-        RT::Transaction
-        RT::Attachment
-        RT::User
-        RT::Principal
-        RT::Template
-        RT::Queue
-        RT::ScripAction
-        RT::ScripCondition
-        RT::Scrip
-        RT::Group
-        RT::GroupMember
-        RT::CustomField
-        RT::CustomFieldValue
-        RT::ObjectCustomField
-        RT::ObjectCustomFieldValue
-        RT::Attribute
-    );
-
-    if ( $args{'Heavy'} ) {
-        # load scrips' modules
-        my $scrips = RT::Scrips->new($RT::SystemUser);
-        $scrips->Limit( FIELD => 'Stage', OPERATOR => '!=', VALUE => 'Disabled' );
-        while ( my $scrip = $scrips->Next ) {
-            local $@;
-            eval { $scrip->LoadModules } or
-                $RT::Logger->error("Invalid Scrip ".$scrip->Id.".  Unable to load the Action or Condition.  ".
-                                   "You should delete or repair this Scrip in the admin UI.\n$@\n");
-        }
-
-       foreach my $class ( grep $_, RT->Config->Get('CustomFieldValuesSources') ) {
-            local $@;
-            eval "require $class; 1" or $RT::Logger->error(
-                "Class '$class' is listed in CustomFieldValuesSources option"
-                ." in the config, but we failed to load it:\n$@\n"
-            );
-        }
-
-        RT::I18N->LoadLexicons;
-    }
-}
-
-=head2 InitSystemObjects
-
-Initializes system objects: C<$RT::System>, C<$RT::SystemUser>
-and C<$RT::Nobody>.
-
-=cut
-
-sub InitSystemObjects {
-
-    #RT's system user is a genuine database user. its id lives here
-    require RT::CurrentUser;
-    $SystemUser = new RT::CurrentUser;
-    $SystemUser->LoadByName('RT_System');
-
-    #RT's "nobody user" is a genuine database user. its ID lives here.
-    $Nobody = new RT::CurrentUser;
-    $Nobody->LoadByName('Nobody');
-
-    require RT::System;
-    $System = RT::System->new( $SystemUser );
-}
-
-=head1 CLASS METHODS
-
-=head2 Config
-
-Returns the current L<config object RT::Config>, but note that
-you must L<load config /LoadConfig> first otherwise this method
-returns undef.
-
-Method can be called as class method.
-
-=cut
-
-sub Config { return $Config }
-
-=head2 DatabaseHandle
-
-Returns the current L<database handle object RT::Handle>.
-
-See also L</ConnectToDatabase>.
-
-=cut
-
-sub DatabaseHandle { return $Handle }
-
-=head2 Logger
-
-Returns the logger. See also L</InitLogging>.
-
-=cut
-
-sub Logger { return $Logger }
-
-=head2 System
-
-Returns the current L<system object RT::System>. See also
-L</InitSystemObjects>.
-
-=cut
-
-sub System { return $System }
-
-=head2 SystemUser
-
-Returns the system user's object, it's object of
-L<RT::CurrentUser> class that represents the system. See also
-L</InitSystemObjects>.
-
-=cut
-
-sub SystemUser { return $SystemUser }
-
-=head2 Nobody
-
-Returns object of Nobody. It's object of L<RT::CurrentUser> class
-that represents a user who can own ticket and nothing else. See
-also L</InitSystemObjects>.
-
-=cut
-
-sub Nobody { return $Nobody }
-
-=head2 Plugins
-
-Returns a listref of all Plugins currently configured for this RT instance.
-You can define plugins by adding them to the @Plugins list in your RT_SiteConfig
-
-=cut
-
-our @PLUGINS = ();
-sub Plugins {
-    my $self = shift;
-    unless (@PLUGINS) {
-        $self->InitPluginPaths;
-        @PLUGINS = $self->InitPlugins;
-    }
-    return \@PLUGINS;
-}
-
-=head2 PluginDirs
-
-Takes optional subdir (e.g. po, lib, etc.) and return plugins' dirs that exist.
-
-This code chacke plugins names or anything else and required when main config
-is loaded to load plugins' configs.
-
-=cut
-
-sub PluginDirs {
-    my $self = shift;
-    my $subdir = shift;
-
-    require RT::Plugin;
-
-    my @res;
-    foreach my $plugin (grep $_, RT->Config->Get('Plugins')) {
-        my $path = RT::Plugin->new( name => $plugin )->Path( $subdir );
-        next unless -d $path;
-        push @res, $path;
-    }
-    return @res;
-}
-
-=head2 InitPluginPaths
-
-Push plugins' lib paths into @INC right after F<local/lib>.
-In case F<local/lib> isn't in @INC, append them to @INC
-
-=cut
-
-sub InitPluginPaths {
-    my $self = shift || __PACKAGE__;
-
-    my @lib_dirs = $self->PluginDirs('lib');
-
-    my @tmp_inc;
-    my $added;
-    for (@INC) {
-        if ( Cwd::realpath($_) eq $RT::LocalLibPath) {
-            push @tmp_inc, $_, @lib_dirs;
-            $added = 1;
-        } else {
-            push @tmp_inc, $_;
-        }
-    }
-
-    # append @lib_dirs in case $RT::LocalLibPath isn't in @INC
-    push @tmp_inc, @lib_dirs unless $added;
-
-    my %seen;
-    @INC = grep !$seen{$_}++, @tmp_inc;
-}
-
-=head2 InitPlugins
-
-Initialze all Plugins found in the RT configuration file, setting up their lib and HTML::Mason component roots.
-
-=cut
-
-sub InitPlugins {
-    my $self    = shift;
-    my @plugins;
-    require RT::Plugin;
-    foreach my $plugin (grep $_, RT->Config->Get('Plugins')) {
-        $plugin->require;
-        die $UNIVERSAL::require::ERROR if ($UNIVERSAL::require::ERROR);
-        push @plugins, RT::Plugin->new(name =>$plugin);
-    }
-    return @plugins;
-}
-
-
-sub InstallMode {
-    my $self = shift;
-    if (@_) {
-         $_INSTALL_MODE = shift;
-         if($_INSTALL_MODE) {
-             require RT::CurrentUser;
-            $SystemUser = RT::CurrentUser->new();
-         }
-    }
-    return $_INSTALL_MODE;
-}
-
-
-=head1 BUGS
-
-Please report them to rt-bugs@bestpractical.com, if you know what's
-broken and have at least some idea of what needs to be fixed.
-
-If you're not sure what's going on, report them rt-devel@lists.bestpractical.com.
-
-=head1 SEE ALSO
-
-L<RT::StyleGuide>
-L<DBIx::SearchBuilder>
-
-
-=cut
-
-require RT::Base;
-RT::Base->_ImportOverlays();
-
-1;
index 31489c8..efd2bda 100644 (file)
@@ -567,7 +567,8 @@ sub Parse {
         $self->_ParseMultilineTemplate(%args);
     } elsif ( $args{'Content'} =~ /(?:\t|,)/i ) {
         $self->_ParseXSVTemplate(%args);
-
+    } else {
+        RT->Logger->error("Invalid Template Content (Couldn't find ===, and is not a csv/tsv template) - unable to parse: $args{Content}");
     }
 }
 
index 8dd661d..47d0ebe 100644 (file)
@@ -360,6 +360,7 @@ sub LimitCustomField {
             QUOTEVALUE      => $args{'QUOTEVALUE'},
             ENTRYAGGREGATOR => 'AND', #$args{'ENTRYAGGREGATOR'},
             SUBCLAUSE       => $clause,
+            CASESENSITIVE   => 0,
         );
         $self->SUPER::Limit(
             ALIAS           => $ObjectValuesAlias,
@@ -380,6 +381,7 @@ sub LimitCustomField {
             QUOTEVALUE      => $args{'QUOTEVALUE'},
             ENTRYAGGREGATOR => $args{'ENTRYAGGREGATOR'},
             SUBCLAUSE       => $clause,
+            CASESENSITIVE   => 0,
         );
         $self->SUPER::Limit(
             ALIAS           => $ObjectValuesAlias,
@@ -389,6 +391,7 @@ sub LimitCustomField {
             QUOTEVALUE      => $args{'QUOTEVALUE'},
             ENTRYAGGREGATOR => $args{'ENTRYAGGREGATOR'},
             SUBCLAUSE       => $clause,
+            CASESENSITIVE   => 0,
         );
     }
 }
index f87ef84..014c764 100644 (file)
@@ -411,8 +411,8 @@ our %META = (
             Description => q|What tickets to display in the 'More about requestor' box|,                #loc
             Values      => [qw(Active Inactive All None)],
             ValuesLabel => {
-                Active   => "Show the Requestor's 10 highest priority open tickets",                  #loc
-                Inactive => "Show the Requestor's 10 highest priority closed tickets",      #loc
+                Active   => "Show the Requestor's 10 highest priority active tickets",                  #loc
+                Inactive => "Show the Requestor's 10 highest priority inactive tickets",      #loc
                 All      => "Show the Requestor's 10 highest priority tickets",      #loc
                 None     => "Show no tickets for the Requestor", #loc
             },
@@ -749,7 +749,7 @@ our %META = (
 
             my %seen;
             foreach my $encoding ( grep defined && length, splice @$value ) {
-                next if $seen{ $encoding }++;
+                next if $seen{ $encoding };
                 if ( $encoding eq '*' ) {
                     unshift @$value, '*';
                     next;
index ab444d0..c5fb12b 100644 (file)
@@ -1683,6 +1683,7 @@ my %ignore_keyword = map { $_ => 1 } qw(
     BEGIN_ENCRYPTION SIG_ID VALIDSIG
     ENC_TO BEGIN_DECRYPTION END_DECRYPTION GOODMDC
     TRUST_UNDEFINED TRUST_NEVER TRUST_MARGINAL TRUST_FULLY TRUST_ULTIMATE
+    DECRYPTION_INFO
 );
 
 sub ParseStatus {
index 14ffa6a..2e2bbc4 100644 (file)
@@ -454,6 +454,36 @@ sub CurrentUserCanCreateAny {
     return 0;
 }
 
+=head2 Delete
+
+Deletes the dashboard and related subscriptions.
+Returns a tuple of status and message, where status is true upon success.
+
+=cut
+
+sub Delete {
+    my $self = shift;
+    my $id = $self->id;
+    my ( $status, $msg ) = $self->SUPER::Delete(@_);
+    if ( $status ) {
+        # delete all the subscriptions
+        my $subscriptions = RT::Attributes->new( RT->SystemUser );
+        $subscriptions->Limit(
+            FIELD => 'Name',
+            VALUE => 'Subscription',
+        );
+        $subscriptions->Limit(
+            FIELD => 'Description',
+            VALUE => "Subscription to dashboard $id",
+        );
+        while ( my $subscription = $subscriptions->Next ) {
+            $subscription->Delete();
+        }
+    }
+
+    return ( $status, $msg );
+}
+
 RT::Base->_ImportOverlays();
 
 1;
index 2abcf3b..9fd946f 100644 (file)
@@ -50,7 +50,7 @@ package RT;
 use warnings;
 use strict;
 
-our $VERSION = '4.0.6';
+our $VERSION = '4.0.7';
 
 
 
index cadf7cc..e453cfa 100644 (file)
@@ -227,7 +227,7 @@ sub SetMIMEEntityToEncoding {
 
     my $body = $entity->bodyhandle;
 
-    if ( $enc ne $charset && $body ) {
+    if ( $body && ($enc ne $charset || $enc =~ /^utf-?8(?:-strict)?$/i) ) {
         my $string = $body->as_string or return;
 
         $RT::Logger->debug( "Converting '$charset' to '$enc' for "
@@ -335,7 +335,7 @@ sub DecodeMIMEWordsToEncoding {
             }
 
             # now we have got a decoded subject, try to convert into the encoding
-            unless ( $charset eq $to_charset ) {
+            if ( $charset ne $to_charset || $charset =~ /^utf-?8(?:-strict)?$/i ) {
                 Encode::from_to( $enc_str, $charset, $to_charset );
             }
 
@@ -537,7 +537,7 @@ sub SetMIMEHeadToEncoding {
         my @values = $head->get_all($tag);
         $head->delete($tag);
         foreach my $value (@values) {
-            if ( $charset ne $enc ) {
+            if ( $charset ne $enc || $enc =~ /^utf-?8(?:-strict)?$/i ) {
                 Encode::_utf8_off($value);
                 Encode::from_to( $value, $charset => $enc );
             }
index 02a1ec0..4c3ee99 100755 (executable)
@@ -787,7 +787,7 @@ sub GetForwardFrom {
     my $ticket = $args{Ticket} || $txn->Object;
 
     if ( RT->Config->Get('ForwardFromUser') ) {
-        return ( $txn || $ticket )->CurrentUser->UserObj->EmailAddress;
+        return ( $txn || $ticket )->CurrentUser->EmailAddress;
     }
     else {
         return $ticket->QueueObj->CorrespondAddress
@@ -1221,8 +1221,16 @@ sub SetInReplyTo {
         if @references > 10;
 
     my $mail = $args{'Message'};
-    $mail->head->set( 'In-Reply-To' => join ' ', @rtid? (@rtid) : (@id) ) if @id || @rtid;
-    $mail->head->set( 'References' => join ' ', @references );
+    $mail->head->set( 'In-Reply-To' => Encode::encode_utf8(join ' ', @rtid? (@rtid) : (@id)) ) if @id || @rtid;
+    $mail->head->set( 'References' => Encode::encode_utf8(join ' ', @references) );
+}
+
+sub ExtractTicketId {
+    my $entity = shift;
+
+    my $subject = $entity->head->get('Subject') || '';
+    chomp $subject;
+    return ParseTicketId( $subject );
 }
 
 sub ParseTicketId {
@@ -1448,7 +1456,7 @@ sub Gateway {
     }
     # }}}
 
-    $args{'ticket'} ||= ParseTicketId( $Subject );
+    $args{'ticket'} ||= ExtractTicketId( $Message );
 
     $SystemTicket = RT::Ticket->new( RT->SystemUser );
     $SystemTicket->Load( $args{'ticket'} ) if ( $args{'ticket'} ) ;
@@ -1704,17 +1712,20 @@ sub _RunUnsafeAction {
             return ( 0, "Ticket not taken" );
         }
     } elsif ( $args{'Action'} =~ /^resolve$/i ) {
-        my ( $status, $msg ) = $args{'Ticket'}->SetStatus('resolved');
-        unless ($status) {
+        my $new_status = $args{'Ticket'}->FirstInactiveStatus;
+        if ($new_status) {
+            my ( $status, $msg ) = $args{'Ticket'}->SetStatus($new_status);
+            unless ($status) {
 
-            #Warn the sender that we couldn't actually submit the comment.
-            MailError(
-                To          => $args{'ErrorsTo'},
-                Subject     => "Ticket not resolved",
-                Explanation => $msg,
-                MIMEObj     => $args{'Message'}
-            );
-            return ( 0, "Ticket not resolved" );
+                #Warn the sender that we couldn't actually submit the comment.
+                MailError(
+                    To          => $args{'ErrorsTo'},
+                    Subject     => "Ticket not resolved",
+                    Explanation => $msg,
+                    MIMEObj     => $args{'Message'}
+                );
+                return ( 0, "Ticket not resolved" );
+            }
         }
     } else {
         return ( 0, "Not supported unsafe action $args{'Action'}", $args{'Ticket'} );
index 94da307..1aae758 100644 (file)
@@ -261,7 +261,15 @@ sub HandleRequest {
 
     $HTML::Mason::Commands::m->comp( '/Elements/SetupSessionCookie', %$ARGS );
     SendSessionCookie();
-    $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new() unless _UserLoggedIn();
+
+    if ( _UserLoggedIn() ) {
+        # make user info up to date
+        $HTML::Mason::Commands::session{'CurrentUser'}
+          ->Load( $HTML::Mason::Commands::session{'CurrentUser'}->id );
+    }
+    else {
+        $HTML::Mason::Commands::session{'CurrentUser'} = RT::CurrentUser->new();
+    }
 
     # Process session-related callbacks before any auth attempts
     $HTML::Mason::Commands::m->callback( %$ARGS, CallbackName => 'Session', CallbackPage => '/autohandler' );
@@ -287,7 +295,7 @@ sub HandleRequest {
             my $m = $HTML::Mason::Commands::m;
 
             # REST urls get a special 401 response
-            if ($m->request_comp->path =~ '^/REST/\d+\.\d+/') {
+            if ($m->request_comp->path =~ m{^/REST/\d+\.\d+/}) {
                 $HTML::Mason::Commands::r->content_type("text/plain");
                 $m->error_format("text");
                 $m->out("RT/$RT::VERSION 401 Credentials required\n");
@@ -457,7 +465,7 @@ sub MaybeShowInstallModePage {
     my $m = $HTML::Mason::Commands::m;
     if ( $m->base_comp->path =~ RT->Config->Get('WebNoAuthRegex') ) {
         $m->call_next();
-    } elsif ( $m->request_comp->path !~ '^(/+)Install/' ) {
+    } elsif ( $m->request_comp->path !~ m{^(/+)Install/} ) {
         RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "Install/index.html" );
     } else {
         $m->call_next();
@@ -557,7 +565,7 @@ sub ShowRequestedPage {
     unless ( $HTML::Mason::Commands::session{'CurrentUser'}->Privileged ) {
 
         # if the user is trying to access a ticket, redirect them
-        if ( $m->request_comp->path =~ '^(/+)Ticket/Display.html' && $ARGS->{'id'} ) {
+        if ( $m->request_comp->path =~ m{^(/+)Ticket/Display.html} && $ARGS->{'id'} ) {
             RT::Interface::Web::Redirect( RT->Config->Get('WebURL') . "SelfService/Display.html?id=" . $ARGS->{'id'} );
         }
 
@@ -659,7 +667,7 @@ sub AttemptExternalAuth {
             delete $HTML::Mason::Commands::session{'CurrentUser'};
             $user = $orig_user;
 
-            if ( RT->Config->Get('WebExternalOnly') ) {
+            unless ( RT->Config->Get('WebFallbackToInternalAuth') ) {
                 TangentForLoginWithError('You are not an authorized user');
             }
         }
@@ -970,7 +978,7 @@ sub MobileClient {
     my $self = shift;
 
 
-if (($ENV{'HTTP_USER_AGENT'} || '') =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60)/io && !$HTML::Mason::Commands::session{'NotMobile'})  {
+if (($ENV{'HTTP_USER_AGENT'} || '') =~ /(?:hiptop|Blazer|Novarra|Vagabond|SonyEricsson|Symbian|NetFront|UP.Browser|UP.Link|Windows CE|MIDP|J2ME|DoCoMo|J-PHONE|PalmOS|PalmSource|iPhone|iPod|AvantGo|Nokia|Android|WebOS|S60|Mobile)/io && !$HTML::Mason::Commands::session{'NotMobile'})  {
     return 1;
 } else {
     return undef;
@@ -1183,6 +1191,14 @@ our %is_whitelisted_component = (
     # information for the search.  Because it's a straight-up read, in
     # addition to embedding its own auth, it's fine.
     '/NoAuth/rss/dhandler' => 1,
+
+    # While these can be used for denial-of-service against RT
+    # (construct a very inefficient query and trick lots of users into
+    # running them against RT) it's incredibly useful to be able to link
+    # to a search result or bookmark a result page.
+    '/Search/Results.html' => 1,
+    '/Search/Simple.html'  => 1,
+    '/m/tickets/search'     => 1,
 );
 
 sub IsCompCSRFWhitelisted {
@@ -1237,7 +1253,19 @@ sub IsRefererCSRFWhitelisted {
     my $configs;
     for my $config ( $base_url, RT->Config->Get('ReferrerWhitelist') ) {
         push @$configs,$config;
-        return 1 if $referer->host_port eq $config;
+
+        my $host_port = $referer->host_port;
+        if ($config =~ /\*/) {
+            # Turn a literal * into a domain component or partial component match.
+            # Refer to http://tools.ietf.org/html/rfc2818#page-5
+            my $regex = join "[a-zA-Z0-9\-]*",
+                         map { quotemeta($_) }
+                       split /\*/, $config;
+
+            return 1 if $host_port =~ /^$regex$/i;
+        } else {
+            return 1 if $host_port eq $config;
+        }
     }
 
     return (0,$referer,$configs);
@@ -1962,7 +1990,7 @@ sub MakeMIMEEntity {
     );
     my $Message = MIME::Entity->build(
         Type    => 'multipart/mixed',
-        "Message-Id" => RT::Interface::Email::GenMessageId,
+        "Message-Id" => Encode::encode_utf8( RT::Interface::Email::GenMessageId ),
         map { $_ => Encode::encode_utf8( $args{ $_} ) }
             grep defined $args{$_}, qw(Subject From Cc)
     );
index e134178..fd238de 100755 (executable)
@@ -639,6 +639,8 @@ sub __Value {
 
     my $value = $self->SUPER::__Value($field);
 
+    return undef if (!defined $value);
+
     if ( $args{'decode_utf8'} ) {
         if ( !utf8::is_utf8($value) ) {
             utf8::decode($value);
@@ -1675,7 +1677,7 @@ sub _AddCustomFieldValue {
             0,
             $self->loc(
                 "Custom field [_1] does not apply to this object",
-                $args{'Field'}
+                ref $args{'Field'} ? $args{'Field'}->id : $args{'Field'}
             )
         );
     }
index 9506616..8f97e74 100755 (executable)
@@ -545,7 +545,7 @@ sub _Set {
         }
     }
 
-    return $self->__Set(@_);
+    return $self->SUPER::_Set(@_);
 }
 
 
index 13a4b7d..fa33f7e 100755 (executable)
@@ -178,16 +178,6 @@ Commit all of this object's prepared scrips
 sub Commit {
     my $self = shift;
 
-    # RT::Scrips->_SetupSourceObjects will clobber
-    # the CurrentUser, but we need to keep this ticket
-    # so that the _TransactionBatch cache is maintained
-    # and doesn't run twice.  sigh.
-    $self->_StashCurrentUser( TicketObj => $self->{TicketObj} ) if $self->{TicketObj};
-
-    #We're really going to need a non-acled ticket for the scrips to work
-    $self->_SetupSourceObjects( TicketObj      => $self->{'TicketObj'},
-                                TransactionObj => $self->{'TransactionObj'} );
-    
     foreach my $scrip (@{$self->Prepared}) {
         $RT::Logger->debug(
             "Committing scrip #". $scrip->id
@@ -199,8 +189,6 @@ sub Commit {
                         TransactionObj => $self->{'TransactionObj'} );
     }
 
-    # Apply the bandaid.
-    $self->_RestoreCurrentUser( TicketObj => $self->{TicketObj} ) if $self->{TicketObj};
 }
 
 
@@ -221,12 +209,6 @@ sub Prepare {
                  Type           => undef,
                  @_ );
 
-    # RT::Scrips->_SetupSourceObjects will clobber
-    # the CurrentUser, but we need to keep this ticket
-    # so that the _TransactionBatch cache is maintained
-    # and doesn't run twice.  sigh.
-    $self->_StashCurrentUser( TicketObj => $args{TicketObj} ) if $args{TicketObj};
-
     #We're really going to need a non-acled ticket for the scrips to work
     $self->_SetupSourceObjects( TicketObj      => $args{'TicketObj'},
                                 Ticket         => $args{'Ticket'},
@@ -259,10 +241,6 @@ sub Prepare {
 
     }
 
-    # Apply the bandaid.
-    $self->_RestoreCurrentUser( TicketObj => $args{TicketObj} ) if $args{TicketObj};
-
-
     return (@{$self->Prepared});
 
 };
@@ -279,40 +257,6 @@ sub Prepared {
     return ($self->{'prepared_scrips'} || []);
 }
 
-=head2 _StashCurrentUser TicketObj => RT::Ticket
-
-Saves aside the current user of the original ticket that was passed to these scrips.
-This is used to make sure that we don't accidentally leak the RT_System current user
-back to the calling code.
-
-=cut
-
-sub _StashCurrentUser {
-    my $self = shift;
-    my %args = @_;
-
-    $self->{_TicketCurrentUser} = $args{TicketObj}->CurrentUser;
-}
-
-=head2 _RestoreCurrentUser TicketObj => RT::Ticket
-
-Uses the current user saved by _StashCurrentUser to reset a Ticket object
-back to the caller's current user and avoid leaking an RT_System ticket to
-calling code.
-
-=cut
-
-sub _RestoreCurrentUser {
-    my $self = shift;
-    my %args = @_;
-    unless ( $self->{_TicketCurrentUser} ) {
-        RT->Logger->debug("Called _RestoreCurrentUser without a stashed current user object");
-        return;
-    }
-    $args{TicketObj}->CurrentUser($self->{_TicketCurrentUser});
-
-}
-
 =head2  _SetupSourceObjects { TicketObj , Ticket, Transaction, TransactionObj }
 
 Setup a ticket and transaction for this Scrip collection to work with as it runs through the 
@@ -334,14 +278,22 @@ sub _SetupSourceObjects {
             @_ );
 
 
-    if ( $self->{'TicketObj'} = $args{'TicketObj'} ) {
-        # This clobbers the passed in TicketObj by turning it into one
-        # whose current user is RT_System.  Anywhere in the Web UI
-        # currently calling into this is thus susceptable to a privilege
-        # leak; the only current call site is ->Apply, which bandaids
-        # over the top of this by re-asserting the CurrentUser
-        # afterwards.
-        $self->{'TicketObj'}->CurrentUser( $self->CurrentUser );
+    if ( $args{'TicketObj'} ) {
+        # This loads a clean copy of the Ticket object to ensure that we
+        # don't accidentally escalate the privileges of the passed in
+        # ticket (this function can be invoked from the UI).
+        # We copy the TransactionBatch transactions so that Scrips
+        # running against the new Ticket will have access to them. We
+        # use RanTransactionBatch to guard against running
+        # TransactionBatch Scrips more than once.
+        $self->{'TicketObj'} = RT::Ticket->new( $self->CurrentUser );
+        $self->{'TicketObj'}->Load( $args{'TicketObj'}->Id );
+        if ( $args{'TicketObj'}->TransactionBatch ) {
+            # try to ensure that we won't infinite loop if something dies, triggering DESTROY while 
+            # we have the _TransactionBatch objects;
+            $self->{'TicketObj'}->RanTransactionBatch(1);
+            $self->{'TicketObj'}->{'_TransactionBatch'} = $args{'TicketObj'}->{'_TransactionBatch'};
+        }
     }
     else {
         $self->{'TicketObj'} = RT::Ticket->new( $self->CurrentUser );
index a125483..1b4071f 100644 (file)
@@ -110,7 +110,7 @@ sub QueryToSQL {
                             (\w+)  # A straight word
                             (?:\.  # With an optional .foo
                                 ($RE{delimited}{-delim=>q['"]}
-                                |\w+
+                                |[\w-]+  # Allow \w + dashes
                                 ) # Which could be ."foo bar", too
                             )?
                         )
@@ -225,6 +225,11 @@ sub GuessType {
     return "default";
 }
 
+# $_[0] is $self
+# $_[1] is escaped value without surrounding single quotes
+# $_[2] is a boolean of "was quoted by the user?"
+#       ensure this is false before you do smart matching like $_[1] eq "me"
+# $_[3] is escaped subkey, if any (see HandleCf)
 sub HandleDefault   { return subject   => "Subject LIKE '$_[1]'"; }
 sub HandleSubject   { return subject   => "Subject LIKE '$_[1]'"; }
 sub HandleFulltext  { return content   => "Content LIKE '$_[1]'"; }
@@ -242,7 +247,14 @@ sub HandleStatus    {
     }
 }
 sub HandleOwner     {
-    return owner  => (!$_[2] and $_[1] eq "me") ? "Owner.id = '__CurrentUser__'" : "Owner = '$_[1]'";
+    if (!$_[2] and $_[1] eq "me") {
+        return owner => "Owner.id = '__CurrentUser__'";
+    }
+    elsif (!$_[2] and $_[1] =~ /\w+@\w+/) {
+        return owner => "Owner.EmailAddress = '$_[1]'";
+    } else {
+        return owner => "Owner = '$_[1]'";
+    }
 }
 sub HandleWatcher     {
     return watcher => (!$_[2] and $_[1] eq "me") ? "Watcher.id = '__CurrentUser__'" : "Watcher = '$_[1]'";
index 3e98551..4278f75 100644 (file)
@@ -211,29 +211,35 @@ sub LimitCustomField {
                  @_ );
 
     my $alias = $self->Join(
-       TYPE       => 'left',
-       ALIAS1     => 'main',
-       FIELD1     => 'id',
-       TABLE2     => 'ObjectCustomFieldValues',
-       FIELD2     => 'ObjectId'
+        TYPE       => 'left',
+        ALIAS1     => 'main',
+        FIELD1     => 'id',
+        TABLE2     => 'ObjectCustomFieldValues',
+        FIELD2     => 'ObjectId'
     );
     $self->Limit(
-       ALIAS      => $alias,
-       FIELD      => 'CustomField',
-       OPERATOR   => '=',
-       VALUE      => $args{'CUSTOMFIELD'},
+        ALIAS      => $alias,
+        FIELD      => 'CustomField',
+        OPERATOR   => '=',
+        VALUE      => $args{'CUSTOMFIELD'},
     ) if ($args{'CUSTOMFIELD'});
     $self->Limit(
-       ALIAS      => $alias,
-       FIELD      => 'ObjectType',
-       OPERATOR   => '=',
-       VALUE      => $self->_SingularClass,
+        ALIAS      => $alias,
+        FIELD      => 'ObjectType',
+        OPERATOR   => '=',
+        VALUE      => $self->_SingularClass,
     );
     $self->Limit(
-       ALIAS      => $alias,
-       FIELD      => 'Content',
-       OPERATOR   => $args{'OPERATOR'},
-       VALUE      => $args{'VALUE'},
+        ALIAS      => $alias,
+        FIELD      => 'Content',
+        OPERATOR   => $args{'OPERATOR'},
+        VALUE      => $args{'VALUE'},
+    );
+    $self->Limit(
+        ALIAS => $alias,
+        FIELD => 'Disabled',
+        OPERATOR => '=',
+        VALUE => 0,
     );
 }
 
index 40c73b3..4f96e16 100644 (file)
@@ -539,9 +539,9 @@ sub WipeoutAll
 {
     my $self = $_[0];
 
-    while ( my ($k, $v) = each %{ $self->{'cache'} } ) {
-        next if $v->{'State'} & (WIPED | IN_WIPING);
-        $self->Wipeout( Object => $v->{'Object'} );
+    foreach my $cache_val ( values %{ $self->{'cache'} } ) {
+        next if $cache_val->{'State'} & (WIPED | IN_WIPING);
+        $self->Wipeout( Object => $cache_val->{'Object'} );
     }
 }
 
index 7d69dd6..3e7c910 100644 (file)
@@ -131,14 +131,14 @@ sub import {
 
     if (RT->Config->Get('DevelMode')) { require Module::Refresh; }
 
-    $class->bootstrap_db( %args );
-
     RT::InitPluginPaths();
+    RT::InitClasses();
+
+    $class->bootstrap_db( %args );
 
     __reconnect_rt()
         unless $args{nodb};
 
-    RT::InitClasses();
     RT::InitLogging();
 
     RT->Plugins;
index 00f88b6..577c444 100755 (executable)
@@ -1124,7 +1124,7 @@ sub AddWatcher {
         return (0, $self->loc("Couldn't parse address from '[_1]' string", $args{'Email'} ))
             unless $addr;
 
-        if ( lc $self->CurrentUser->UserObj->EmailAddress
+        if ( lc $self->CurrentUser->EmailAddress
             eq lc RT::User->CanonicalizeEmailAddress( $addr->address ) )
         {
             $args{'PrincipalId'} = $self->CurrentUser->id;
@@ -1305,7 +1305,7 @@ sub DeleteWatcher {
             }
         }
         else {
-            $RT::Logger->warn("$self -> DeleteWatcher got passed a bogus type");
+            $RT::Logger->warning("$self -> DeleteWatcher got passed a bogus type");
             return ( 0,
                      $self->loc('Error in parameters to Ticket->DeleteWatcher') );
         }
@@ -1989,6 +1989,31 @@ sub FirstActiveStatus {
     return $next;
 }
 
+=head2 FirstInactiveStatus
+
+Returns the first inactive status that the ticket could transition to,
+according to its current Queue's lifecycle.  May return undef if there
+is no such possible status to transition to, or we are already in it.
+This is used in resolve action in UnsafeEmailCommands, for instance.
+
+=cut
+
+sub FirstInactiveStatus {
+    my $self = shift;
+
+    my $lifecycle = $self->QueueObj->Lifecycle;
+    my $status = $self->Status;
+    my @inactive = $lifecycle->Inactive;
+    # no change if no inactive statuses in the lifecycle
+    return undef unless @inactive;
+
+    # no change if the ticket is already has first status from the list of inactive
+    return undef if lc $status eq lc $inactive[0];
+
+    my ($next) = grep $lifecycle->IsInactive($_), $lifecycle->Transitions($status);
+    return $next;
+}
+
 =head2 SetStarted
 
 Takes a date in ISO format or undef
@@ -2315,7 +2340,9 @@ sub _RecordNote {
     my $msgid = $args{'MIMEObj'}->head->get('Message-ID');
     unless (defined $msgid && $msgid =~ /<(rt-.*?-\d+-\d+)\.(\d+-0-0)\@\Q$org\E>/) {
         $args{'MIMEObj'}->head->set(
-            'RT-Message-ID' => RT::Interface::Email::GenMessageId( Ticket => $self )
+            'RT-Message-ID' => Encode::encode_utf8(
+                RT::Interface::Email::GenMessageId( Ticket => $self )
+            )
         );
     }
 
@@ -3340,6 +3367,28 @@ sub SeenUpTo {
     return $txns->First;
 }
 
+=head2 RanTransactionBatch
+
+Acts as a guard around running TransactionBatch scrips.
+
+Should be false until you enter the code that runs TransactionBatch scrips
+
+Accepts an optional argument to indicate that TransactionBatch Scrips should no longer be run on this object.
+
+=cut
+
+sub RanTransactionBatch {
+    my $self = shift;
+    my $val = shift;
+
+    if ( defined $val ) {
+        return $self->{_RanTransactionBatch} = $val;
+    } else {
+        return $self->{_RanTransactionBatch};
+    }
+
+}
+
 
 =head2 TransactionBatch
 
@@ -3376,6 +3425,22 @@ sub ApplyTransactionBatch {
 
 sub _ApplyTransactionBatch {
     my $self = shift;
+
+    return if $self->RanTransactionBatch;
+    $self->RanTransactionBatch(1);
+
+    my $still_exists = RT::Ticket->new( RT->SystemUser );
+    $still_exists->Load( $self->Id );
+    if (not $still_exists->Id) {
+        # The ticket has been removed from the database, but we still
+        # have pending TransactionBatch txns for it.  Unfortunately,
+        # because it isn't in the DB anymore, attempting to run scrips
+        # on it may produce unpredictable results; simply drop the
+        # batched transactions.
+        $RT::Logger->warning("TransactionBatch was fired on a ticket that no longer exists; unable to run scrips!  Call ->ApplyTransactionBatch before shredding the ticket, for consistent results.");
+        return;
+    }
+
     my $batch = $self->TransactionBatch;
 
     my %seen;
@@ -3423,10 +3488,7 @@ sub DESTROY {
         return;
     }
 
-    my $batch = $self->TransactionBatch;
-    return unless $batch && @$batch;
-
-    return $self->_ApplyTransactionBatch;
+    return $self->ApplyTransactionBatch;
 }
 
 
index 485d7df..c9986f4 100755 (executable)
@@ -436,6 +436,10 @@ sub _LinkLimit {
     my $is_null = 0;
     $is_null = 1 if !$value || $value =~ /^null$/io;
 
+    unless ($is_null) {
+        $value = RT::URI->new( $sb->CurrentUser )->CanonicalizeURI( $value );
+    }
+
     my $direction = $meta->[1] || '';
     my ($matchfield, $linkfield) = ('', '');
     if ( $direction eq 'To' ) {
@@ -1651,6 +1655,7 @@ sub _CustomFieldLimit {
                 FIELD      => $column,
                 OPERATOR   => $op,
                 VALUE      => $value,
+                CASESENSITIVE => 0,
                 %rest
             ) );
             $self->_CloseParen;
@@ -1713,6 +1718,7 @@ sub _CustomFieldLimit {
                         FIELD    => 'Content',
                         OPERATOR => $op,
                         VALUE    => $value,
+                        CASESENSITIVE => 0,
                         %rest
                     );
                 }
@@ -1739,6 +1745,7 @@ sub _CustomFieldLimit {
                         OPERATOR        => $op,
                         VALUE           => $value,
                         ENTRYAGGREGATOR => 'AND',
+                        CASESENSITIVE => 0,
                     ) );
                 }
             }
@@ -1748,6 +1755,7 @@ sub _CustomFieldLimit {
                     FIELD    => 'Content',
                     OPERATOR => $op,
                     VALUE    => $value,
+                    CASESENSITIVE => 0,
                     %rest
                 );
 
@@ -1774,6 +1782,7 @@ sub _CustomFieldLimit {
                     OPERATOR        => $op,
                     VALUE           => $value,
                     ENTRYAGGREGATOR => 'AND',
+                    CASESENSITIVE => 0,
                 ) );
                 $self->_CloseParen;
             }
@@ -1830,6 +1839,7 @@ sub _CustomFieldLimit {
                 FIELD      => $column,
                 OPERATOR   => $op,
                 VALUE      => $value,
+                CASESENSITIVE => 0,
             ) );
         }
         else {
@@ -1839,6 +1849,7 @@ sub _CustomFieldLimit {
                 FIELD      => 'Content',
                 OPERATOR   => $op,
                 VALUE      => $value,
+                CASESENSITIVE => 0,
             );
         }
         $self->_SQLLimit(
index bd4d835..3344687 100755 (executable)
@@ -133,12 +133,6 @@ sub Create {
         return ( 0, $self->loc( "Transaction->Create couldn't, as you didn't specify an object type and id"));
     }
 
-
-    # Set up any custom fields passed at creation.  Has to happen 
-    # before scrips.
-    
-    $self->UpdateCustomFields(%{ $args{'CustomFields'} });
-
     #lets create our transaction
     my %params = (
         Type      => $args{'Type'},
@@ -169,6 +163,11 @@ sub Create {
         }
     }
 
+    # Set up any custom fields passed at creation.  Has to happen 
+    # before scrips.
+    
+    $self->UpdateCustomFields(%{ $args{'CustomFields'} });
+
     $self->AddAttribute(
         Name    => 'SquelchMailTo',
         Content => RT::User->CanonicalizeEmailAddress($_)
index fce0459..284a75e 100644 (file)
@@ -91,7 +91,26 @@ sub new {
     return ($self);
 }
 
+=head2 CanonicalizeURI <URI>
 
+Returns the canonical form of the given URI by calling L</FromURI> and then L</URI>.
+
+If the URI is unparseable by FromURI the passed in URI is simply returned untouched.
+
+=cut
+
+sub CanonicalizeURI {
+    my $self = shift;
+    my $uri  = shift;
+    if ($self->FromURI($uri)) {
+        my $canonical = $self->URI;
+        if ($canonical and $uri ne $canonical) {
+            RT->Logger->debug("Canonicalizing URI '$uri' to '$canonical'");
+            $uri = $canonical;
+        }
+    }
+    return $uri;
+}
 
 
 =head2 FromObject <Object>
index 9b4a826..e7f7c2a 100755 (executable)
@@ -932,7 +932,7 @@ sub IsPassword {
         # crypt() output
         return 0 unless crypt(encode_utf8($value), $stored) eq $stored;
     } else {
-        $RT::Logger->warn("Unknown password form");
+        $RT::Logger->warning("Unknown password form");
         return 0;
     }
 
index 45c3770..f84f6c1 100644 (file)
@@ -172,7 +172,7 @@ if (caller) {
 require Plack::Runner;
 
 my $is_fastcgi = $0 =~ m/fcgi$/;
-my $r = Plack::Runner->new( $0 =~ 'standalone' ? ( server => 'Standalone' ) :
+my $r = Plack::Runner->new( $0 =~ /standalone/ ? ( server => 'Standalone' ) :
                             $is_fastcgi        ? ( server => 'FCGI' )
                                                : (),
                             env => 'deployment' );
index 45c3770..f84f6c1 100644 (file)
@@ -172,7 +172,7 @@ if (caller) {
 require Plack::Runner;
 
 my $is_fastcgi = $0 =~ m/fcgi$/;
-my $r = Plack::Runner->new( $0 =~ 'standalone' ? ( server => 'Standalone' ) :
+my $r = Plack::Runner->new( $0 =~ /standalone/ ? ( server => 'Standalone' ) :
                             $is_fastcgi        ? ( server => 'FCGI' )
                                                : (),
                             env => 'deployment' );
index 37ef32f..960d640 100644 (file)
@@ -56,9 +56,10 @@ no warnings qw(numeric redefine);
 use Getopt::Long;
 my %args;
 my %deps;
+my @orig_argv = @ARGV;
 GetOptions(
     \%args,                               'v|verbose',
-    'install',                            'with-MYSQL',
+    'install!',                           'with-MYSQL',
     'with-POSTGRESQL|with-pg|with-pgsql', 'with-SQLITE',
     'with-ORACLE',                        'with-FASTCGI',
     'with-MODPERL1',                      'with-MODPERL2',
@@ -293,7 +294,7 @@ Test::LongString
 .
 
 $deps{'FASTCGI'} = [ text_to_hash( << '.') ];
-FCGI
+FCGI 0.74
 FCGI::ProcManager
 .
 
@@ -344,7 +345,7 @@ URI 1.59
 
 $deps{'GRAPHVIZ'} = [ text_to_hash( << '.') ];
 GraphViz
-IPC::Run
+IPC::Run 0.90
 .
 
 $deps{'GD'} = [ text_to_hash( << '.') ];
@@ -359,6 +360,7 @@ Convert::Color
 
 my %AVOID = (
     'DBD::Oracle' => [qw(1.23)],
+    'Email::Address' => [qw(1.893 1.894)],
 );
 
 if ($args{'download'}) {
@@ -403,7 +405,12 @@ foreach my $type (sort grep $args{$_}, keys %args) {
     $Missing_By_Type{$type} = \%missing if keys %missing;
 }
 
-conclude(%Missing_By_Type);
+if ( $args{'install'} && keys %Missing_By_Type ) {
+    exec($0, @orig_argv, '--no-install');
+}
+else {
+    conclude(%Missing_By_Type);
+}
 
 sub test_deps {
     my @deps = @_;
index 3386cd1..cef0f31 100755 (executable)
@@ -172,7 +172,7 @@ if (caller) {
 require Plack::Runner;
 
 my $is_fastcgi = $0 =~ m/fcgi$/;
-my $r = Plack::Runner->new( $0 =~ 'standalone' ? ( server => 'Standalone' ) :
+my $r = Plack::Runner->new( $0 =~ /standalone/ ? ( server => 'Standalone' ) :
                             $is_fastcgi        ? ( server => 'FCGI' )
                                                : (),
                             env => 'deployment' );
index 45c3770..f84f6c1 100644 (file)
@@ -172,7 +172,7 @@ if (caller) {
 require Plack::Runner;
 
 my $is_fastcgi = $0 =~ m/fcgi$/;
-my $r = Plack::Runner->new( $0 =~ 'standalone' ? ( server => 'Standalone' ) :
+my $r = Plack::Runner->new( $0 =~ /standalone/ ? ( server => 'Standalone' ) :
                             $is_fastcgi        ? ( server => 'FCGI' )
                                                : (),
                             env => 'deployment' );
index 5682eee..85cd62f 100755 (executable)
@@ -51,7 +51,7 @@
 
 
 
-<form action="<%RT->Config->Get('WebPath')%>/Admin/Queues/Modify.html" name="ModifyQueue" method="post">
+<form action="<%RT->Config->Get('WebPath')%>/Admin/Queues/Modify.html" name="ModifyQueue" method="post" enctype="multipart/form-data">
 <input type="hidden" class="hidden" name="SetEnabled" value="1" />
 <input type="hidden" class="hidden" name="id" value="<% $Create? 'new': $QueueObj->Id %>" />
 
index d2061da..169c25c 100755 (executable)
@@ -74,7 +74,7 @@ $tickets->LimitOwner( VALUE => $session{'CurrentUser'}->Id );
 
 # also consider AdminCcs as potential approvers.
 my $group_tickets = RT::Tickets->new( $session{'CurrentUser'} );
-$group_tickets->LimitWatcher( VALUE => $session{'CurrentUser'}->UserObj->EmailAddress, TYPE => 'AdminCc' );
+$group_tickets->LimitWatcher( VALUE => $session{'CurrentUser'}->EmailAddress, TYPE => 'AdminCc' );
 
 my $created_before = RT::Date->new( $session{'CurrentUser'} );
 my $created_after = RT::Date->new( $session{'CurrentUser'} );
index a057706..3e0f2c6 100644 (file)
 %#
 %# END BPS TAGGED BLOCK }}}
 <%init>
-$m->call_next(%ARGS) if $session{'CurrentUser'}->UserObj->HasRight(
+if ( $session{'CurrentUser'}->UserObj->HasRight(
     Right => 'ShowApprovalsTab',
     Object => $RT::System,
-);
+) ) {
+    $m->call_next(%ARGS);
+}
+else {
+    Abort("No permission to view approval");
+}
 </%init>
index 3669e46..3a57102 100644 (file)
 <&|/l&>Recipient</&>:
 </td><td class="value">
 <input name="Recipient" id="Recipient" size="30" value="<%$fields{Recipient} ? $fields{Recipient} : ''%>" />
-<div class="hints"><% loc("Leave blank to send to your current email address ([_1])", $session{'CurrentUser'}->UserObj->EmailAddress) %></div>
+<div class="hints"><% loc("Leave blank to send to your current email address ([_1])", $session{'CurrentUser'}->EmailAddress) %></div>
 </td></tr>
 </table>
 </&>
index 9828d7d..6517db4 100644 (file)
@@ -41,7 +41,7 @@ my @Customers = ();
 if ( $CustomerString ) {
     @Customers = &RT::URI::freeside::smart_search(
         'search'            => $CustomerString,
-        'no_fuzzy_on_exact' => 1, #pref?
+        'no_fuzzy_on_exact' => ! $FS::CurrentUser::CurrentUser->option('enable_fuzzy_on_exact'),
     );
 }
 
index b9c3b4b..f268a5d 100644 (file)
@@ -118,7 +118,7 @@ my $COLUMN_MAP = {
     CheckBox => {
         title => sub {
             my $name = $_[1] || 'SelectedTickets';
-            my $checked = $m->request_args->{ $name .'All' }? 'checked="checked"': '';
+            my $checked = $DECODED_ARGS->{ $name .'All' }? 'checked="checked"': '';
 
             return \qq{<input type="checkbox" name="}, $name, \qq{All" value="1" $checked
                               onclick="setCheckbox(this.form, },
@@ -130,9 +130,9 @@ my $COLUMN_MAP = {
 
             my $name = $_[2] || 'SelectedTickets';
             return \qq{<input type="checkbox" name="}, $name, \qq{" value="$id" checked="checked" />}
-                if $m->request_args->{ $name . 'All'};
+                if $DECODED_ARGS->{ $name . 'All'};
 
-            my $arg = $m->request_args->{ $name };
+            my $arg = $DECODED_ARGS->{ $name };
             my $checked = '';
             if ( $arg && ref $arg ) {
                 $checked = 'checked="checked"' if grep $_ == $id, @$arg;
@@ -149,7 +149,7 @@ my $COLUMN_MAP = {
             my $id = $_[0]->id;
 
             my $name = $_[2] || 'SelectedTicket';
-            my $arg = $m->request_args->{ $name };
+            my $arg = $DECODED_ARGS->{ $name };
             my $checked = '';
             $checked = 'checked="checked"' if $arg && $arg == $id;
             return \qq{<input type="radio" name="}, $name, \qq{" value="$id" $checked />};
index b74c484..8b87fd4 100644 (file)
@@ -71,7 +71,7 @@ if ( $Object && $Object->id ) {
 
 # Always fill $Default with submited values if it's empty
 if ( ( !defined $Default || !length $Default ) && $DefaultsFromTopArguments ) {
-    my %TOP = $m->request_args;
+    my %TOP = %$DECODED_ARGS;
     $Default = $TOP{ $NamePrefix .$CustomField->Id . '-Values' }
             || $TOP{ $NamePrefix .$CustomField->Id . '-Value' };
 }
index 1830c4b..65d06f8 100755 (executable)
@@ -130,7 +130,8 @@ if ($m->comp_exists($stylesheet_plugin) ) {
 # $m->callback( %ARGS, CallbackName => 'Head' );
 $head .= $m->scomp( '/Elements/Callback', _CallbackName => 'Head', %ARGS );
 
-my $etc = qq[ class="\L$style" ];
+my $sbs = RT->Config->Get("UseSideBySideLayout", $session{'CurrentUser'}) ? ' sidebyside' : '';
+my $etc = qq[ class="\L$style$sbs" ];
 $etc .= qq[ id="comp-$id"] if $id;
 
 </%INIT>
index 28788db..d5741f4 100644 (file)
@@ -67,7 +67,7 @@ $onload => undef
 % }
 
 % if ( $RichText and RT->Config->Get('MessageBoxRichText',  $session{'CurrentUser'})) {
-    jQuery().ready(function ()  { ReplaceAllTextareas(<%$m->request_args->{'CKeditorEncoded'} || 0 |n,j%>) });
+    jQuery().ready(function ()  { ReplaceAllTextareas(<%$DECODED_ARGS->{'CKeditorEncoded'} || 0 |n,j%>) });
 % }
 --></script>
 <%ARGS>
index 999d3fe..8929ff7 100755 (executable)
@@ -65,7 +65,7 @@ if ( ref( $session{'Actions'}{''} ) eq 'ARRAY' ) {
     unshift @actions, @{ delete $session{'Actions'}{''} };
 }
 
-my $actions_pointer = $m->request_args->{'results'};
+my $actions_pointer = $DECODED_ARGS->{'results'};
 
 if ($actions_pointer &&  ref( $session{'Actions'}->{$actions_pointer} ) eq 'ARRAY' ) {
     unshift @actions, @{ delete $session{'Actions'}->{$actions_pointer} };
index 61995e0..69227bf 100755 (executable)
@@ -46,7 +46,7 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 <textarea autocomplete="off" class="messagebox" <% $width_attr %>="<% $Width %>" rows="<% $Height %>" <% $wrap_type |n %> name="<% $Name %>" id="<% $Name %>">\
-% $m->comp('/Articles/Elements/IncludeArticle', %ARGS);
+% $m->comp('/Articles/Elements/IncludeArticle', %ARGS) if $IncludeArticle;
 % $m->callback( %ARGS, SignatureRef => \$signature );
 <% $Default || '' %><% $message %><% $signature %></textarea>
 % $m->callback( %ARGS, CallbackName => 'AfterTextArea' );
@@ -89,4 +89,5 @@ $Width            => RT->Config->Get('MessageBoxWidth', $session{'CurrentUser'}
 $Height           => RT->Config->Get('MessageBoxHeight', $session{'CurrentUser'} ) || 15
 $Wrap             => RT->Config->Get('MessageBoxWrap', $session{'CurrentUser'} ) || 'SOFT'
 $IncludeSignature => RT->Config->Get('MessageBoxIncludeSignature');
+$IncludeArticle   => 1;
 </%ARGS>
index 09f274f..f649d28 100644 (file)
@@ -122,9 +122,13 @@ my $statuses = {};
 
 use RT::Report::Tickets;
 my $report = RT::Report::Tickets->new( RT->SystemUser );
-my $query = @queues
-    ? join(' OR ', map "Queue = ".$_->{id}, @queues)
-    : 'id < 0';
+my $query =
+    "(".
+    join(" OR ", map {s{(['\\])}{\\$1}g; "Status = '$_'"} @statuses) #'
+    .") AND (".
+    join(' OR ', map "Queue = ".$_->{id}, @queues)
+    .")";
+$query = 'id < 0' unless @queues;
 $report->SetupGroupings( Query => $query, GroupBy => [qw(Status Queue)] );
 
 while ( my $entry = $report->Next ) {
index ecb219d..b043984 100644 (file)
@@ -118,7 +118,7 @@ my $COLUMN_MAP = {
     RemoveCheckBox => {
         title => sub {
             my $name = 'RemoveCustomField';
-            my $checked = $m->request_args->{ $name .'All' }? 'checked="checked"': '';
+            my $checked = $DECODED_ARGS->{ $name .'All' }? 'checked="checked"': '';
 
             return \qq{<input type="checkbox" name="}, $name, \qq{All" value="1" $checked
                               onclick="setCheckbox(this.form, },
@@ -130,7 +130,7 @@ my $COLUMN_MAP = {
             return '' if $_[0]->IsApplied;
 
             my $name = 'RemoveCustomField';
-            my $arg = $m->request_args->{ $name };
+            my $arg = $DECODED_ARGS->{ $name };
 
             my $checked = '';
             if ( $arg && ref $arg ) {
index 44beee0..4f1df60 100755 (executable)
@@ -56,7 +56,7 @@
 
 <%INIT>
 my @types;
-if ($Scope =~ 'queue') {
+if ($Scope =~ /queue/) {
    @types = RT::Queue->ManageableRoleGroupTypes;
 }
 else { 
index 3193b48..3aac9d8 100755 (executable)
@@ -845,7 +845,7 @@ my $build_selfservice_nav = sub {
     } elsif ( $queue_id ) {
         Menu->child( new => title => loc('New ticket'), path => '/SelfService/Create.html?Queue=' . $queue_id );
     }
-    my $tickets = Menu->child( tickets => title => loc('Tickets'));
+    my $tickets = Menu->child( tickets => title => loc('Tickets'), path => '/SelfService/' );
     $tickets->child( open   => title => loc('Open tickets'),   path => '/SelfService/' );
     $tickets->child( closed => title => loc('Closed tickets'), path => '/SelfService/Closed.html' );
 
index dbc2d88..c2b92c1 100644 (file)
@@ -116,6 +116,9 @@ foreach (split /\s*,\s*/, $exclude) {
 
 my @suggestions;
 
+$users->Limit( FIELD => $return, OPERATOR => '!=', VALUE => '' );
+$users->Limit( FIELD => $return, OPERATOR => 'IS NOT', VALUE => 'NULL', ENTRYAGGREGATOR => 'AND'  );
+
 while ( my $user = $users->Next ) {
     next if $user->id == RT->SystemUser->id
          or $user->id == RT->Nobody->id;
index ed6623c..f90ac9f 100644 (file)
 
 .titlebox .titlebox-title {
  position: relative;
- /* This is for [rt3 #19044]. Move it to an IE-specific file if it causes
-  * problems. If we remove CSS3PIE, it can also probably go away, although it
-  * probably won't hurt. */
- z-index: 1;
 }
 
 .titlebox .titlebox-title a {
index 4d069d9..7b573f7 100644 (file)
@@ -87,8 +87,7 @@ div#ticket-history {
  float: left;
  margin: 0.25em 0.70em 0.25em 0.25em;
  width: 1em;
- height: 1.25em;
- padding: 0.75em 0 0 0;
+ padding: 0;
  border-right: 1px solid #999;
  border-bottom: 1px solid #999;
  -moz-border-radius-bottomright: 0.25em;
@@ -100,6 +99,16 @@ div#ticket-history {
 
 div#ticket-history span.type a {
  color: #fff;
+ padding-top: 0.75em;
+ display: block;
+}
+
+#ticket-history a#lasttrans {
+    display: inline;
+    height: 0;
+    width: 0;
+    padding: 0;
+    margin: 0;
 }
 
 
index 912ac55..9610cd5 100644 (file)
@@ -54,6 +54,7 @@
  margin-left: 1em;
  -moz-border-radius: 0.5em;
  -webkit-border-radius: 0.5em;
+ border-radius: 0.5em;
  margin-bottom: 2em;
  border-bottom: 2px solid #aaa;
  border-right: 2px solid #aaa;
@@ -71,6 +72,7 @@
  margin-top: 1em;
  -moz-border-radius: 0.5em;
  -webkit-border-radius: 0.5em;
+ border-radius: 0.5em;
  margin-right: 0.25em;
  
 }
     padding-right: 0.75em;
     -moz-border-radius: 0.5em;
     -webkit-border-radius: 0.5em;
+    border-radius: 0.5em;
     border-bottom: 2px solid #aaa;
     border-right: 2px solid #aaa;
 
  padding-top: 0.5em;
  -moz-border-radius-bottomleft: 0.25em;
  -webkit-border-bottom-left-radius: 0.25em;
+ border-bottom-left-radius: 0.25em;
  
  
  -moz-border-radius-topright: 0.25em;
  -webkit-border-top-right-radius: 0.25em;
+ border-top-right-radius: 0.25em;
 
 }
 
index 8dc0cc1..8b600b8 100644 (file)
@@ -60,8 +60,10 @@ div#body {
     padding: 1.8em 1em 1em 1em;
     -moz-border-radius-topleft: 0.5em;
     -webkit-border-top-left-radius: 0.5em;
+    border-top-left-radius: 0.5em;
     -moz-border-radius-bottomleft: 0.5em;
     -webkit-border-bottom-left-radius: 0.5em;
+    border-bottom-left-radius: 0.5em;
     margin-left: 10em;
     margin-top: 3em;
     margin-right: 0;
@@ -89,8 +91,10 @@ div#footer {
  border-left: 2px solid #aaa;
  -moz-border-radius-topleft: 0.5em;
  -webkit-border-top-left-radius: 0.5em;
+ border-top-left-radius: 0.5em;
  -moz-border-radius-bottomleft: 0.5em;
  -webkit-border-bottom-left-radius: 0.5em;
+ border-bottom-left-radius: 0.5em;
 }
 
 div#footer #time {
index 196f0e6..dc29818 100644 (file)
     background-color: #fff;
     -moz-border-radius-bottomright: 0.5em;
     -webkit-border-bottom-right-radius: 0.5em;
+    border-bottom-right-radius: 0.5em;
     -moz-border-radius-topright: 0.5em;
     -webkit-border-top-right-radius: 0.5em;
+    border-top-right-radius: 0.5em;
     width: 10em;
     font-size: 0.85em;
     position: absolute;
     border: 1px solid #ccc;
     -moz-border-radius-bottomleft: 0.5em;
     -webkit-border-bottom-left-radius: 0.5em;
+    border-bottom-left-radius: 0.5em;
     padding: 0;
     padding-top: 0.5em;
     padding-right: 0.5em;
index 19ee847..fb252b5 100644 (file)
  border-bottom: 1px solid #999;
  -moz-border-radius-bottomleft: 0.5em;
  -webkit-border-bottom-left-radius: 0.5em;
+ border-bottom-left-radius: 0.5em;
 }
 
 
index 06b6678..4d416e1 100644 (file)
@@ -77,6 +77,7 @@ div#ticket-history {
  color: #ccc;
  -moz-border-radius-bottomleft: 0.5em;
  -webkit-border-bottom-left-radius: 0.5em;
+ border-bottom-left-radius: 0.5em;
  white-space: nowrap;
 }
 
@@ -91,6 +92,7 @@ div#ticket-history {
  border-bottom: 1px solid #999;
  -moz-border-radius: 0.25em;
  -webkit-border-bottom-right-radius: 0.25em;
+ border-bottom-right-radius: 0.25em;
 }
 
 div#ticket-history span.type a {
@@ -150,6 +152,7 @@ border-bottom: 2px solid #aaa;
 margin-top: 0.5em;
 -moz-border-radius: 0.5em;
 -webkit-border-radius: 0.5em;
+border-radius: 0.5em;
 
 }
 
index eab97b1..19af1b2 100644 (file)
@@ -87,6 +87,7 @@ input[type=reset], input[type=submit], input[class=button], button {
    padding-right: 0.5em;
    -moz-border-radius: 0.5em;
    -webkit-border-radius: 0.5em;
+   border-radius: 0.5em;
 }
 
 input.button:hover, button:hover, input[type=reset]:hover, input[type=submit]:hover, input[class=button]:hover {
diff --git a/rt/share/html/NoAuth/css/base/jquery-ui-timepicker-addon.css b/rt/share/html/NoAuth/css/base/jquery-ui-timepicker-addon.css
new file mode 100644 (file)
index 0000000..7eb8715
--- /dev/null
@@ -0,0 +1,7 @@
+.ui-timepicker-div .ui-widget-header { margin-bottom: 8px; }
+.ui-timepicker-div dl { text-align: left; }
+.ui-timepicker-div dl dt { height: 25px; margin-bottom: -25px; }
+.ui-timepicker-div dl dd { margin: 0 10px 10px 65px; }
+.ui-timepicker-div td { font-size: 90%; }
+.ui-tpicker-grid-label { background: none; border: none; margin: 0; padding: 0; }
+.ui-datepicker-buttonpane button.ui-datepicker-current { opacity: 1.0; }
index 820996e..8fe4f15 100644 (file)
@@ -46,5 +46,3 @@
 %#
 %# END BPS TAGGED BLOCK }}}
 @import "jquery-ui.custom.modified.css";
-@import "ui.timepickr.css";
-@import "ui.timepickr.custom.css";
index 7a32322..3b1e1a0 100644 (file)
     width: 200px; /*must have*/
     height: 200px; /*must have*/
 }
+/*
+ * jQuery UI Slider 1.8.4
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Slider#theming
+ */
+.ui-slider { position: relative; text-align: left; }
+.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; }
+.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; }
+
+.ui-slider-horizontal { height: .8em; }
+.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; }
+.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; }
+.ui-slider-horizontal .ui-slider-range-min { left: 0; }
+.ui-slider-horizontal .ui-slider-range-max { right: 0; }
+
+.ui-slider-vertical { width: .8em; height: 100px; }
+.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; }
+.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; }
+.ui-slider-vertical .ui-slider-range-min { bottom: 0; }
+.ui-slider-vertical .ui-slider-range-max { top: 0; }
index 9f77c8a..dac733d 100644 (file)
@@ -49,6 +49,7 @@
 
 @import "yui-fonts.css";
 @import "jquery-ui.css";
+@import "jquery-ui-timepicker-addon.css";
 @import "superfish.css";
 @import "superfish-navbar.css";
 @import "superfish-vertical.css";
index 9a3f24c..459156e 100644 (file)
@@ -90,4 +90,6 @@ ul.sf-navbar .current ul ul {
        -moz-border-radius-topright: 0;
        -webkit-border-top-right-radius: 0;
        -webkit-border-bottom-left-radius: 0;
+       border-top-right-radius: 0;
+       border-bottom-left-radius: 0;
 }
index 31198e4..7cb3b56 100644 (file)
@@ -130,6 +130,8 @@ li.sfHover > a > .sf-sub-indicator {
        -moz-border-radius-topright: 17px;
        -webkit-border-top-right-radius: 17px;
        -webkit-border-bottom-left-radius: 17px;
+       border-top-right-radius: 17px;
+       border-bottom-left-radius: 17px;
 }
 .sf-shadow ul.sf-shadow-off {
        background: transparent;
index daab263..869eba7 100644 (file)
@@ -82,21 +82,17 @@ iframe.richtext-editor {
 .messagebox-container.action-response iframe
 {
     background-color: #fcc !important;
-} 
-
-/*
-% if ( RT->Config->Get("UseSideBySideLayout", $session{'CurrentUser'}) ) {
-*/
+}
 
-#ticket-create-metadata,
-#ticket-update-metadata {
+.sidebyside #ticket-create-metadata,
+.sidebyside #ticket-update-metadata {
     float: right;
     width: 40%;
     clear: right;
 }
 
-#ticket-create-message,
-#ticket-update-message {
+.sidebyside #ticket-create-message,
+.sidebyside #ticket-update-message {
     float: left;
     width: 58%;
     clear: left;
@@ -104,10 +100,10 @@ iframe.richtext-editor {
 
 @media (max-width: 950px) {
     /* Revert to a single column when we're less than 1000px wide */
-    #ticket-create-metadata,
-    #ticket-update-metadata,
-    #ticket-create-message,
-    #ticket-update-message
+    .sidebyside #ticket-create-metadata,
+    .sidebyside #ticket-update-metadata,
+    .sidebyside #ticket-create-message,
+    .sidebyside #ticket-update-message
     {
         float: none;
         width: auto;
@@ -115,15 +111,12 @@ iframe.richtext-editor {
     }
 }
 
-#comp-Ticket-Update #body {
+.sidebyside #comp-Ticket-Update #body {
     padding-top: 3em;
 }
 
-#ticket-create-message .button[name="AddMoreAttach"],
-#ticket-update-message .button[name="AddMoreAttach"] {
+.sidebyside #ticket-create-message .button[name="AddMoreAttach"],
+.sidebyside #ticket-update-message .button[name="AddMoreAttach"] {
     float: right;
 }
 
-/*
-% }
-*/
diff --git a/rt/share/html/NoAuth/css/base/ui.timepickr.css b/rt/share/html/NoAuth/css/base/ui.timepickr.css
deleted file mode 100644 (file)
index e2dacf7..0000000
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
-  jQuery ui.timepickr
-  http://code.google.com/p/jquery-utils/
-
-  copyright Maxime Haineault <haineault@gmail.com> 
-  http://haineault.com
-
-  MIT License (http://www.opensource.org/licenses/mit-license.php
-*/
-.ui-timepickr {
-    position:absolute;
-    width:480px; 
-}
-
-.ui-timepickr-row {
-    margin:0;
-    padding:0;
-    margin-top:2px; 
-    display:none;
-    position:relative;
-}
-
-.ui-timepickr-button {
-    float:left;
-    margin:0;
-    padding:0;
-    list-style:none;
-    list-style-type:none;
-}
-
-.ui-timepickr-button span {
-    font-size:.7em;
-    padding:4px 6px 4px 6px;
-    margin-left:2px;
-    text-align:center;
-    cursor:pointer;
-    display:block;
-    text-align:center;
-
-
-    /* system theme (default) */
-    border-width:1px;
-    border-style:solid;
-    /*border-color:ThreeDLightShadow ThreeDShadow ThreeDShadow ThreeDLightShadow;
-    color:ButtonText;
-    background:ButtonFace;*/
-}
-
-.ui-timepickr-button span.ui-state-hover {
-    /*color:HighlightText;
-    background:Highlight;*/
-}
-
-.ui-state-hover span {
-    /*background:#c30;*/
-}
diff --git a/rt/share/html/NoAuth/css/base/ui.timepickr.custom.css b/rt/share/html/NoAuth/css/base/ui.timepickr.custom.css
deleted file mode 100644 (file)
index ad2aa66..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-%# BEGIN BPS TAGGED BLOCK {{{
-%#
-%# COPYRIGHT:
-%#
-%# This software is Copyright (c) 1996-2012 Best Practical Solutions, LLC
-%#                                          <sales@bestpractical.com>
-%#
-%# (Except where explicitly superseded by other copyright notices)
-%#
-%#
-%# LICENSE:
-%#
-%# This work is made available to you under the terms of Version 2 of
-%# the GNU General Public License. A copy of that license should have
-%# been provided with this software, but in any event can be snarfed
-%# from www.gnu.org.
-%#
-%# This work is distributed in the hope that it will be useful, but
-%# WITHOUT ANY WARRANTY; without even the implied warranty of
-%# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
-%# General Public License for more details.
-%#
-%# You should have received a copy of the GNU General Public License
-%# along with this program; if not, write to the Free Software
-%# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
-%# 02110-1301 or visit their web page on the internet at
-%# http://www.gnu.org/licenses/old-licenses/gpl-2.0.html.
-%#
-%#
-%# CONTRIBUTION SUBMISSION POLICY:
-%#
-%# (The following paragraph is not intended to limit the rights granted
-%# to you to modify and distribute this software under the terms of
-%# the GNU General Public License and is only of importance to you if
-%# you choose to contribute your changes and enhancements to the
-%# community by submitting them to Best Practical Solutions, LLC.)
-%#
-%# By intentionally submitting any modifications, corrections or
-%# derivatives to this work, or any other work intended for use with
-%# Request Tracker, to Best Practical Solutions, LLC, you confirm that
-%# you are the copyright holder for those contributions and you grant
-%# Best Practical Solutions,  LLC a nonexclusive, worldwide, irrevocable,
-%# royalty-free, perpetual, license to use, copy, create derivative
-%# works based on those contributions, and sublicense and distribute
-%# those contributions and any derivatives thereof.
-%#
-%# END BPS TAGGED BLOCK }}}
-.ui-timepickr {
-    font-size: 1.1em;
-}
-
-.ui-timepickr-button span {
-    background: white;
-}
index be63c59..e404b61 100644 (file)
     border: 1px solid #ccc;
     -moz-border-radius-bottomleft: 0.5em;
     -webkit-border-bottom-left-radius: 0.5em;
+    border-bottom-left-radius: 0.5em;
     border-right: none;
     border-top: none;
     list-style-type: none;
index e90b4fe..0466005 100644 (file)
@@ -222,3 +222,53 @@ c=this._daylightSavingAdjust(new Date(c,e+(b<0?b:f[0]*f[1]),1));b<0&&c.setDate(t
 function(a){if(!d.datepicker.initialized){d(document).mousedown(d.datepicker._checkExternalClick).find("body").append(d.datepicker.dpDiv);d.datepicker.initialized=true}var b=Array.prototype.slice.call(arguments,1);if(typeof a=="string"&&(a=="isDisabled"||a=="getDate"||a=="widget"))return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));if(a=="option"&&arguments.length==2&&typeof arguments[1]=="string")return d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this[0]].concat(b));
 return this.each(function(){typeof a=="string"?d.datepicker["_"+a+"Datepicker"].apply(d.datepicker,[this].concat(b)):d.datepicker._attachDatepicker(this,a)})};d.datepicker=new L;d.datepicker.initialized=false;d.datepicker.uuid=(new Date).getTime();d.datepicker.version="1.8.4";window["DP_jQuery_"+y]=d})(jQuery);
 ;
+/*!
+ * jQuery UI Mouse 1.8.4
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Mouse
+ *
+ * Depends:
+ *     jquery.ui.widget.js
+ */
+(function(c){c.widget("ui.mouse",{options:{cancel:":input,option",distance:1,delay:0},_mouseInit:function(){var a=this;this.element.bind("mousedown."+this.widgetName,function(b){return a._mouseDown(b)}).bind("click."+this.widgetName,function(b){if(a._preventClickEvent){a._preventClickEvent=false;b.stopImmediatePropagation();return false}});this.started=false},_mouseDestroy:function(){this.element.unbind("."+this.widgetName)},_mouseDown:function(a){a.originalEvent=a.originalEvent||{};if(!a.originalEvent.mouseHandled){this._mouseStarted&&
+this._mouseUp(a);this._mouseDownEvent=a;var b=this,e=a.which==1,f=typeof this.options.cancel=="string"?c(a.target).parents().add(a.target).filter(this.options.cancel).length:false;if(!e||f||!this._mouseCapture(a))return true;this.mouseDelayMet=!this.options.delay;if(!this.mouseDelayMet)this._mouseDelayTimer=setTimeout(function(){b.mouseDelayMet=true},this.options.delay);if(this._mouseDistanceMet(a)&&this._mouseDelayMet(a)){this._mouseStarted=this._mouseStart(a)!==false;if(!this._mouseStarted){a.preventDefault();
+return true}}this._mouseMoveDelegate=function(d){return b._mouseMove(d)};this._mouseUpDelegate=function(d){return b._mouseUp(d)};c(document).bind("mousemove."+this.widgetName,this._mouseMoveDelegate).bind("mouseup."+this.widgetName,this._mouseUpDelegate);c.browser.safari||a.preventDefault();return a.originalEvent.mouseHandled=true}},_mouseMove:function(a){if(c.browser.msie&&!(document.documentMode>=9)&&!a.button)return this._mouseUp(a);if(this._mouseStarted){this._mouseDrag(a);return a.preventDefault()}if(this._mouseDistanceMet(a)&&
+this._mouseDelayMet(a))(this._mouseStarted=this._mouseStart(this._mouseDownEvent,a)!==false)?this._mouseDrag(a):this._mouseUp(a);return!this._mouseStarted},_mouseUp:function(a){c(document).unbind("mousemove."+this.widgetName,this._mouseMoveDelegate).unbind("mouseup."+this.widgetName,this._mouseUpDelegate);if(this._mouseStarted){this._mouseStarted=false;this._preventClickEvent=a.target==this._mouseDownEvent.target;this._mouseStop(a)}return false},_mouseDistanceMet:function(a){return Math.max(Math.abs(this._mouseDownEvent.pageX-
+a.pageX),Math.abs(this._mouseDownEvent.pageY-a.pageY))>=this.options.distance},_mouseDelayMet:function(){return this.mouseDelayMet},_mouseStart:function(){},_mouseDrag:function(){},_mouseStop:function(){},_mouseCapture:function(){return true}})})(jQuery);
+/*
+ * jQuery UI Slider 1.8.4
+ *
+ * Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
+ * Dual licensed under the MIT or GPL Version 2 licenses.
+ * http://jquery.org/license
+ *
+ * http://docs.jquery.com/UI/Slider
+ *
+ * Depends:
+ *  jquery.ui.core.js
+ *  jquery.ui.mouse.js
+ *  jquery.ui.widget.js
+ */
+(function(d){d.widget("ui.slider",d.ui.mouse,{widgetEventPrefix:"slide",options:{animate:false,distance:0,max:100,min:0,orientation:"horizontal",range:false,step:1,value:0,values:null},_create:function(){var a=this,b=this.options;this._mouseSliding=this._keySliding=false;this._animateOff=true;this._handleIndex=null;this._detectOrientation();this._mouseInit();this.element.addClass("ui-slider ui-slider-"+this.orientation+" ui-widget ui-widget-content ui-corner-all");b.disabled&&this.element.addClass("ui-slider-disabled ui-disabled");
+this.range=d([]);if(b.range){if(b.range===true){this.range=d("<div></div>");if(!b.values)b.values=[this._valueMin(),this._valueMin()];if(b.values.length&&b.values.length!==2)b.values=[b.values[0],b.values[0]]}else this.range=d("<div></div>");this.range.appendTo(this.element).addClass("ui-slider-range");if(b.range==="min"||b.range==="max")this.range.addClass("ui-slider-range-"+b.range);this.range.addClass("ui-widget-header")}d(".ui-slider-handle",this.element).length===0&&d("<a href='#'></a>").appendTo(this.element).addClass("ui-slider-handle");
+if(b.values&&b.values.length)for(;d(".ui-slider-handle",this.element).length<b.values.length;)d("<a href='#'></a>").appendTo(this.element).addClass("ui-slider-handle");this.handles=d(".ui-slider-handle",this.element).addClass("ui-state-default ui-corner-all");this.handle=this.handles.eq(0);this.handles.add(this.range).filter("a").click(function(c){c.preventDefault()}).hover(function(){b.disabled||d(this).addClass("ui-state-hover")},function(){d(this).removeClass("ui-state-hover")}).focus(function(){if(b.disabled)d(this).blur();
+else{d(".ui-slider .ui-state-focus").removeClass("ui-state-focus");d(this).addClass("ui-state-focus")}}).blur(function(){d(this).removeClass("ui-state-focus")});this.handles.each(function(c){d(this).data("index.ui-slider-handle",c)});this.handles.keydown(function(c){var e=true,f=d(this).data("index.ui-slider-handle"),h,g,i;if(!a.options.disabled){switch(c.keyCode){case d.ui.keyCode.HOME:case d.ui.keyCode.END:case d.ui.keyCode.PAGE_UP:case d.ui.keyCode.PAGE_DOWN:case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:e=
+false;if(!a._keySliding){a._keySliding=true;d(this).addClass("ui-state-active");h=a._start(c,f);if(h===false)return}break}i=a.options.step;h=a.options.values&&a.options.values.length?(g=a.values(f)):(g=a.value());switch(c.keyCode){case d.ui.keyCode.HOME:g=a._valueMin();break;case d.ui.keyCode.END:g=a._valueMax();break;case d.ui.keyCode.PAGE_UP:g=a._trimAlignValue(h+(a._valueMax()-a._valueMin())/5);break;case d.ui.keyCode.PAGE_DOWN:g=a._trimAlignValue(h-(a._valueMax()-a._valueMin())/5);break;case d.ui.keyCode.UP:case d.ui.keyCode.RIGHT:if(h===
+a._valueMax())return;g=a._trimAlignValue(h+i);break;case d.ui.keyCode.DOWN:case d.ui.keyCode.LEFT:if(h===a._valueMin())return;g=a._trimAlignValue(h-i);break}a._slide(c,f,g);return e}}).keyup(function(c){var e=d(this).data("index.ui-slider-handle");if(a._keySliding){a._keySliding=false;a._stop(c,e);a._change(c,e);d(this).removeClass("ui-state-active")}});this._refreshValue();this._animateOff=false},destroy:function(){this.handles.remove();this.range.remove();this.element.removeClass("ui-slider ui-slider-horizontal ui-slider-vertical ui-slider-disabled ui-widget ui-widget-content ui-corner-all").removeData("slider").unbind(".slider");
+this._mouseDestroy();return this},_mouseCapture:function(a){var b=this.options,c,e,f,h,g;if(b.disabled)return false;this.elementSize={width:this.element.outerWidth(),height:this.element.outerHeight()};this.elementOffset=this.element.offset();c=this._normValueFromMouse({x:a.pageX,y:a.pageY});e=this._valueMax()-this._valueMin()+1;h=this;this.handles.each(function(i){var j=Math.abs(c-h.values(i));if(e>j){e=j;f=d(this);g=i}});if(b.range===true&&this.values(1)===b.min){g+=1;f=d(this.handles[g])}if(this._start(a,
+g)===false)return false;this._mouseSliding=true;h._handleIndex=g;f.addClass("ui-state-active").focus();b=f.offset();this._clickOffset=!d(a.target).parents().andSelf().is(".ui-slider-handle")?{left:0,top:0}:{left:a.pageX-b.left-f.width()/2,top:a.pageY-b.top-f.height()/2-(parseInt(f.css("borderTopWidth"),10)||0)-(parseInt(f.css("borderBottomWidth"),10)||0)+(parseInt(f.css("marginTop"),10)||0)};this._slide(a,g,c);return this._animateOff=true},_mouseStart:function(){return true},_mouseDrag:function(a){var b=
+this._normValueFromMouse({x:a.pageX,y:a.pageY});this._slide(a,this._handleIndex,b);return false},_mouseStop:function(a){this.handles.removeClass("ui-state-active");this._mouseSliding=false;this._stop(a,this._handleIndex);this._change(a,this._handleIndex);this._clickOffset=this._handleIndex=null;return this._animateOff=false},_detectOrientation:function(){this.orientation=this.options.orientation==="vertical"?"vertical":"horizontal"},_normValueFromMouse:function(a){var b;if(this.orientation==="horizontal"){b=
+this.elementSize.width;a=a.x-this.elementOffset.left-(this._clickOffset?this._clickOffset.left:0)}else{b=this.elementSize.height;a=a.y-this.elementOffset.top-(this._clickOffset?this._clickOffset.top:0)}b=a/b;if(b>1)b=1;if(b<0)b=0;if(this.orientation==="vertical")b=1-b;a=this._valueMax()-this._valueMin();return this._trimAlignValue(this._valueMin()+b*a)},_start:function(a,b){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(b);
+c.values=this.values()}return this._trigger("start",a,c)},_slide:function(a,b,c){var e;if(this.options.values&&this.options.values.length){e=this.values(b?0:1);if(this.options.values.length===2&&this.options.range===true&&(b===0&&c>e||b===1&&c<e))c=e;if(c!==this.values(b)){e=this.values();e[b]=c;a=this._trigger("slide",a,{handle:this.handles[b],value:c,values:e});this.values(b?0:1);a!==false&&this.values(b,c,true)}}else if(c!==this.value()){a=this._trigger("slide",a,{handle:this.handles[b],value:c});
+a!==false&&this.value(c)}},_stop:function(a,b){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(b);c.values=this.values()}this._trigger("stop",a,c)},_change:function(a,b){if(!this._keySliding&&!this._mouseSliding){var c={handle:this.handles[b],value:this.value()};if(this.options.values&&this.options.values.length){c.value=this.values(b);c.values=this.values()}this._trigger("change",a,c)}},value:function(a){if(arguments.length){this.options.value=
+this._trimAlignValue(a);this._refreshValue();this._change(null,0)}return this._value()},values:function(a,b){var c,e,f;if(arguments.length>1){this.options.values[a]=this._trimAlignValue(b);this._refreshValue();this._change(null,a)}if(arguments.length)if(d.isArray(arguments[0])){c=this.options.values;e=arguments[0];for(f=0;f<c.length;f+=1){c[f]=this._trimAlignValue(e[f]);this._change(null,f)}this._refreshValue()}else return this.options.values&&this.options.values.length?this._values(a):this.value();
+else return this._values()},_setOption:function(a,b){var c,e=0;if(d.isArray(this.options.values))e=this.options.values.length;d.Widget.prototype._setOption.apply(this,arguments);switch(a){case "disabled":if(b){this.handles.filter(".ui-state-focus").blur();this.handles.removeClass("ui-state-hover");this.handles.attr("disabled","disabled");this.element.addClass("ui-disabled")}else{this.handles.removeAttr("disabled");this.element.removeClass("ui-disabled")}break;case "orientation":this._detectOrientation();
+this.element.removeClass("ui-slider-horizontal ui-slider-vertical").addClass("ui-slider-"+this.orientation);this._refreshValue();break;case "value":this._animateOff=true;this._refreshValue();this._change(null,0);this._animateOff=false;break;case "values":this._animateOff=true;this._refreshValue();for(c=0;c<e;c+=1)this._change(null,c);this._animateOff=false;break}},_value:function(){var a=this.options.value;return a=this._trimAlignValue(a)},_values:function(a){var b,c;if(arguments.length){b=this.options.values[a];
+return b=this._trimAlignValue(b)}else{b=this.options.values.slice();for(c=0;c<b.length;c+=1)b[c]=this._trimAlignValue(b[c]);return b}},_trimAlignValue:function(a){if(a<this._valueMin())return this._valueMin();if(a>this._valueMax())return this._valueMax();var b=this.options.step>0?this.options.step:1,c=a%b;a=a-c;if(Math.abs(c)*2>=b)a+=c>0?b:-b;return parseFloat(a.toFixed(5))},_valueMin:function(){return this.options.min},_valueMax:function(){return this.options.max},_refreshValue:function(){var a=
+this.options.range,b=this.options,c=this,e=!this._animateOff?b.animate:false,f,h={},g,i,j,l;if(this.options.values&&this.options.values.length)this.handles.each(function(k){f=(c.values(k)-c._valueMin())/(c._valueMax()-c._valueMin())*100;h[c.orientation==="horizontal"?"left":"bottom"]=f+"%";d(this).stop(1,1)[e?"animate":"css"](h,b.animate);if(c.options.range===true)if(c.orientation==="horizontal"){if(k===0)c.range.stop(1,1)[e?"animate":"css"]({left:f+"%"},b.animate);if(k===1)c.range[e?"animate":"css"]({width:f-
+g+"%"},{queue:false,duration:b.animate})}else{if(k===0)c.range.stop(1,1)[e?"animate":"css"]({bottom:f+"%"},b.animate);if(k===1)c.range[e?"animate":"css"]({height:f-g+"%"},{queue:false,duration:b.animate})}g=f});else{i=this.value();j=this._valueMin();l=this._valueMax();f=l!==j?(i-j)/(l-j)*100:0;h[c.orientation==="horizontal"?"left":"bottom"]=f+"%";this.handle.stop(1,1)[e?"animate":"css"](h,b.animate);if(a==="min"&&this.orientation==="horizontal")this.range.stop(1,1)[e?"animate":"css"]({width:f+"%"},
+b.animate);if(a==="max"&&this.orientation==="horizontal")this.range[e?"animate":"css"]({width:100-f+"%"},{queue:false,duration:b.animate});if(a==="min"&&this.orientation==="vertical")this.range.stop(1,1)[e?"animate":"css"]({height:f+"%"},b.animate);if(a==="max"&&this.orientation==="vertical")this.range[e?"animate":"css"]({height:100-f+"%"},{queue:false,duration:b.animate})}}});d.extend(d.ui.slider,{version:"1.8.4"})})(jQuery);
index 40cc0db..2ac101f 100644 (file)
 
         return data;
     };
+
+    $.datepicker._checkOffset_orig = $.datepicker._checkOffset;
+    $.datepicker._checkOffset = function(inst, offset, isFixed) {
+        // copied from the original
+        var dpHeight    = inst.dpDiv.outerHeight();
+        var inputHeight = inst.input ? inst.input.outerHeight() : 0;
+        var viewHeight  = document.documentElement.clientHeight + $(document).scrollTop();
+
+        // save the original offset rather than the new offset because the
+        // original function modifies the passed arg as a side-effect
+        var old_offset = { top: offset.top, left: offset.left };
+        offset = $.datepicker._checkOffset_orig(inst, offset, isFixed);
+
+        // Negate any up or down positioning by adding instead of subtracting
+        offset.top += Math.min(old_offset.top, (old_offset.top + dpHeight > viewHeight && viewHeight > dpHeight) ?
+            Math.abs(dpHeight + inputHeight) : 0);
+
+        return offset;
+    };
+
+
+    $.timepicker._newInst_orig = $.timepicker._newInst;
+    $.timepicker._newInst = function($input, o) {
+        var tp_inst = $.timepicker._newInst_orig($input, o);
+        tp_inst._defaults.onClose = function(dateText, dp_inst) {
+           if ($.isFunction(o.onClose))
+               o.onClose.call($input[0], dateText, dp_inst, tp_inst);
+        };
+        return tp_inst;
+    };
+
 })(jQuery);
diff --git a/rt/share/html/NoAuth/js/jquery-ui-timepicker-addon.js b/rt/share/html/NoAuth/js/jquery-ui-timepicker-addon.js
new file mode 100644 (file)
index 0000000..0a4ff02
--- /dev/null
@@ -0,0 +1,1326 @@
+/*
+* jQuery timepicker addon
+* By: Trent Richardson [http://trentrichardson.com]
+* Version 1.0.0
+* Last Modified: 02/05/2012
+*
+* Copyright 2012 Trent Richardson
+* Dual licensed under the MIT and GPL licenses.
+* http://trentrichardson.com/Impromptu/GPL-LICENSE.txt
+* http://trentrichardson.com/Impromptu/MIT-LICENSE.txt
+*
+* HERES THE CSS:
+* .ui-timepicker-div .ui-widget-header { margin-bottom: 8px; }
+* .ui-timepicker-div dl { text-align: left; }
+* .ui-timepicker-div dl dt { height: 25px; margin-bottom: -25px; }
+* .ui-timepicker-div dl dd { margin: 0 10px 10px 65px; }
+* .ui-timepicker-div td { font-size: 90%; }
+* .ui-tpicker-grid-label { background: none; border: none; margin: 0; padding: 0; }
+*/
+
+(function($) {
+
+// Prevent "Uncaught RangeError: Maximum call stack size exceeded"
+$.ui.timepicker = $.ui.timepicker || {};
+if ($.ui.timepicker.version) {
+       return;
+}
+
+$.extend($.ui, { timepicker: { version: "1.0.0" } });
+
+/* Time picker manager.
+   Use the singleton instance of this class, $.timepicker, to interact with the time picker.
+   Settings for (groups of) time pickers are maintained in an instance object,
+   allowing multiple different settings on the same page. */
+
+function Timepicker() {
+       this.regional = []; // Available regional settings, indexed by language code
+       this.regional[''] = { // Default regional settings
+               currentText: 'Now',
+               closeText: 'Done',
+               ampm: false,
+               amNames: ['AM', 'A'],
+               pmNames: ['PM', 'P'],
+               timeFormat: 'hh:mm tt',
+               timeSuffix: '',
+               timeOnlyTitle: 'Choose Time',
+               timeText: 'Time',
+               hourText: 'Hour',
+               minuteText: 'Minute',
+               secondText: 'Second',
+               millisecText: 'Millisecond',
+               timezoneText: 'Time Zone'
+       };
+       this._defaults = { // Global defaults for all the datetime picker instances
+               showButtonPanel: true,
+               timeOnly: false,
+               showHour: true,
+               showMinute: true,
+               showSecond: false,
+               showMillisec: false,
+               showTimezone: false,
+               showTime: true,
+               stepHour: 1,
+               stepMinute: 1,
+               stepSecond: 1,
+               stepMillisec: 1,
+               hour: 0,
+               minute: 0,
+               second: 0,
+               millisec: 0,
+               timezone: '+0000',
+               hourMin: 0,
+               minuteMin: 0,
+               secondMin: 0,
+               millisecMin: 0,
+               hourMax: 23,
+               minuteMax: 59,
+               secondMax: 59,
+               millisecMax: 999,
+               minDateTime: null,
+               maxDateTime: null,
+               onSelect: null,
+               hourGrid: 0,
+               minuteGrid: 0,
+               secondGrid: 0,
+               millisecGrid: 0,
+               alwaysSetTime: true,
+               separator: ' ',
+               altFieldTimeOnly: true,
+               showTimepicker: true,
+               timezoneIso8609: false,
+               timezoneList: null,
+               addSliderAccess: false,
+               sliderAccessArgs: null
+       };
+       $.extend(this._defaults, this.regional['']);
+};
+
+$.extend(Timepicker.prototype, {
+       $input: null,
+       $altInput: null,
+       $timeObj: null,
+       inst: null,
+       hour_slider: null,
+       minute_slider: null,
+       second_slider: null,
+       millisec_slider: null,
+       timezone_select: null,
+       hour: 0,
+       minute: 0,
+       second: 0,
+       millisec: 0,
+       timezone: '+0000',
+       hourMinOriginal: null,
+       minuteMinOriginal: null,
+       secondMinOriginal: null,
+       millisecMinOriginal: null,
+       hourMaxOriginal: null,
+       minuteMaxOriginal: null,
+       secondMaxOriginal: null,
+       millisecMaxOriginal: null,
+       ampm: '',
+       formattedDate: '',
+       formattedTime: '',
+       formattedDateTime: '',
+       timezoneList: null,
+
+       /* Override the default settings for all instances of the time picker.
+          @param  settings  object - the new settings to use as defaults (anonymous object)
+          @return the manager object */
+       setDefaults: function(settings) {
+               extendRemove(this._defaults, settings || {});
+               return this;
+       },
+
+       //########################################################################
+       // Create a new Timepicker instance
+       //########################################################################
+       _newInst: function($input, o) {
+               var tp_inst = new Timepicker(),
+                       inlineSettings = {};
+
+               for (var attrName in this._defaults) {
+                       var attrValue = $input.attr('time:' + attrName);
+                       if (attrValue) {
+                               try {
+                                       inlineSettings[attrName] = eval(attrValue);
+                               } catch (err) {
+                                       inlineSettings[attrName] = attrValue;
+                               }
+                       }
+               }
+               tp_inst._defaults = $.extend({}, this._defaults, inlineSettings, o, {
+                       beforeShow: function(input, dp_inst) {
+                               if ($.isFunction(o.beforeShow))
+                                       return o.beforeShow(input, dp_inst, tp_inst);
+                       },
+                       onChangeMonthYear: function(year, month, dp_inst) {
+                               // Update the time as well : this prevents the time from disappearing from the $input field.
+                               tp_inst._updateDateTime(dp_inst);
+                               if ($.isFunction(o.onChangeMonthYear))
+                                       o.onChangeMonthYear.call($input[0], year, month, dp_inst, tp_inst);
+                       },
+                       onClose: function(dateText, dp_inst) {
+                               if (tp_inst.timeDefined === true && $input.val() != '')
+                                       tp_inst._updateDateTime(dp_inst);
+                               if ($.isFunction(o.onClose))
+                                       o.onClose.call($input[0], dateText, dp_inst, tp_inst);
+                       },
+                       timepicker: tp_inst // add timepicker as a property of datepicker: $.datepicker._get(dp_inst, 'timepicker');
+               });
+               tp_inst.amNames = $.map(tp_inst._defaults.amNames, function(val) { return val.toUpperCase(); });
+               tp_inst.pmNames = $.map(tp_inst._defaults.pmNames, function(val) { return val.toUpperCase(); });
+
+               if (tp_inst._defaults.timezoneList === null) {
+                       var timezoneList = [];
+                       for (var i = -11; i <= 12; i++)
+                               timezoneList.push((i >= 0 ? '+' : '-') + ('0' + Math.abs(i).toString()).slice(-2) + '00');
+                       if (tp_inst._defaults.timezoneIso8609)
+                               timezoneList = $.map(timezoneList, function(val) {
+                                       return val == '+0000' ? 'Z' : (val.substring(0, 3) + ':' + val.substring(3));
+                               });
+                       tp_inst._defaults.timezoneList = timezoneList;
+               }
+
+               tp_inst.hour = tp_inst._defaults.hour;
+               tp_inst.minute = tp_inst._defaults.minute;
+               tp_inst.second = tp_inst._defaults.second;
+               tp_inst.millisec = tp_inst._defaults.millisec;
+               tp_inst.ampm = '';
+               tp_inst.$input = $input;
+
+               if (o.altField)
+                       tp_inst.$altInput = $(o.altField)
+                               .css({ cursor: 'pointer' })
+                               .focus(function(){ $input.trigger("focus"); });
+
+               if(tp_inst._defaults.minDate==0 || tp_inst._defaults.minDateTime==0)
+               {
+                       tp_inst._defaults.minDate=new Date();
+               }
+               if(tp_inst._defaults.maxDate==0 || tp_inst._defaults.maxDateTime==0)
+               {
+                       tp_inst._defaults.maxDate=new Date();
+               }
+
+               // datepicker needs minDate/maxDate, timepicker needs minDateTime/maxDateTime..
+               if(tp_inst._defaults.minDate !== undefined && tp_inst._defaults.minDate instanceof Date)
+                       tp_inst._defaults.minDateTime = new Date(tp_inst._defaults.minDate.getTime());
+               if(tp_inst._defaults.minDateTime !== undefined && tp_inst._defaults.minDateTime instanceof Date)
+                       tp_inst._defaults.minDate = new Date(tp_inst._defaults.minDateTime.getTime());
+               if(tp_inst._defaults.maxDate !== undefined && tp_inst._defaults.maxDate instanceof Date)
+                       tp_inst._defaults.maxDateTime = new Date(tp_inst._defaults.maxDate.getTime());
+               if(tp_inst._defaults.maxDateTime !== undefined && tp_inst._defaults.maxDateTime instanceof Date)
+                       tp_inst._defaults.maxDate = new Date(tp_inst._defaults.maxDateTime.getTime());
+               return tp_inst;
+       },
+
+       //########################################################################
+       // add our sliders to the calendar
+       //########################################################################
+       _addTimePicker: function(dp_inst) {
+               var currDT = (this.$altInput && this._defaults.altFieldTimeOnly) ?
+                               this.$input.val() + ' ' + this.$altInput.val() :
+                               this.$input.val();
+
+               this.timeDefined = this._parseTime(currDT);
+               this._limitMinMaxDateTime(dp_inst, false);
+               this._injectTimePicker();
+       },
+
+       //########################################################################
+       // parse the time string from input value or _setTime
+       //########################################################################
+       _parseTime: function(timeString, withDate) {
+               var regstr = this._defaults.timeFormat.toString()
+                               .replace(/h{1,2}/ig, '(\\d?\\d)')
+                               .replace(/m{1,2}/ig, '(\\d?\\d)')
+                               .replace(/s{1,2}/ig, '(\\d?\\d)')
+                               .replace(/l{1}/ig, '(\\d?\\d?\\d)')
+                               .replace(/t{1,2}/ig, this._getPatternAmpm())
+                               .replace(/z{1}/ig, '(z|[-+]\\d\\d:?\\d\\d)?')
+                               .replace(/\s/g, '\\s?') + this._defaults.timeSuffix + '$',
+                       order = this._getFormatPositions(),
+                       ampm = '',
+                       treg;
+
+               if (!this.inst) this.inst = $.datepicker._getInst(this.$input[0]);
+
+               if (withDate || !this._defaults.timeOnly) {
+                       // the time should come after x number of characters and a space.
+                       // x = at least the length of text specified by the date format
+                       var dp_dateFormat = $.datepicker._get(this.inst, 'dateFormat');
+                       // escape special regex characters in the seperator
+                       var specials = new RegExp("[.*+?|()\\[\\]{}\\\\]", "g");
+                       regstr = '^.{' + dp_dateFormat.length + ',}?' + this._defaults.separator.replace(specials, "\\$&") + regstr;
+               }
+
+               treg = timeString.match(new RegExp(regstr, 'i'));
+
+               if (treg) {
+                       if (order.t !== -1) {
+                               if (treg[order.t] === undefined || treg[order.t].length === 0) {
+                                       ampm = '';
+                                       this.ampm = '';
+                               } else {
+                                       ampm = $.inArray(treg[order.t].toUpperCase(), this.amNames) !== -1 ? 'AM' : 'PM';
+                                       this.ampm = this._defaults[ampm == 'AM' ? 'amNames' : 'pmNames'][0];
+                               }
+                       }
+
+                       if (order.h !== -1) {
+                               if (ampm == 'AM' && treg[order.h] == '12')
+                                       this.hour = 0; // 12am = 0 hour
+                               else if (ampm == 'PM' && treg[order.h] != '12')
+                                       this.hour = (parseFloat(treg[order.h]) + 12).toFixed(0); // 12pm = 12 hour, any other pm = hour + 12
+                               else this.hour = Number(treg[order.h]);
+                       }
+
+                       if (order.m !== -1) this.minute = Number(treg[order.m]);
+                       if (order.s !== -1) this.second = Number(treg[order.s]);
+                       if (order.l !== -1) this.millisec = Number(treg[order.l]);
+                       if (order.z !== -1 && treg[order.z] !== undefined) {
+                               var tz = treg[order.z].toUpperCase();
+                               switch (tz.length) {
+                               case 1: // Z
+                                       tz = this._defaults.timezoneIso8609 ? 'Z' : '+0000';
+                                       break;
+                               case 5: // +hhmm
+                                       if (this._defaults.timezoneIso8609)
+                                               tz = tz.substring(1) == '0000'
+                                                  ? 'Z'
+                                                  : tz.substring(0, 3) + ':' + tz.substring(3);
+                                       break;
+                               case 6: // +hh:mm
+                                       if (!this._defaults.timezoneIso8609)
+                                               tz = tz == 'Z' || tz.substring(1) == '00:00'
+                                                  ? '+0000'
+                                                  : tz.replace(/:/, '');
+                                       else if (tz.substring(1) == '00:00')
+                                               tz = 'Z';
+                                       break;
+                               }
+                               this.timezone = tz;
+                       }
+
+                       return true;
+
+               }
+               return false;
+       },
+
+       //########################################################################
+       // pattern for standard and localized AM/PM markers
+       //########################################################################
+       _getPatternAmpm: function() {
+               var markers = [],
+                       o = this._defaults;
+               if (o.amNames)
+                       $.merge(markers, o.amNames);
+               if (o.pmNames)
+                       $.merge(markers, o.pmNames);
+               markers = $.map(markers, function(val) { return val.replace(/[.*+?|()\[\]{}\\]/g, '\\$&'); });
+               return '(' + markers.join('|') + ')?';
+       },
+
+       //########################################################################
+       // figure out position of time elements.. cause js cant do named captures
+       //########################################################################
+       _getFormatPositions: function() {
+               var finds = this._defaults.timeFormat.toLowerCase().match(/(h{1,2}|m{1,2}|s{1,2}|l{1}|t{1,2}|z)/g),
+                       orders = { h: -1, m: -1, s: -1, l: -1, t: -1, z: -1 };
+
+               if (finds)
+                       for (var i = 0; i < finds.length; i++)
+                               if (orders[finds[i].toString().charAt(0)] == -1)
+                                       orders[finds[i].toString().charAt(0)] = i + 1;
+
+               return orders;
+       },
+
+       //########################################################################
+       // generate and inject html for timepicker into ui datepicker
+       //########################################################################
+       _injectTimePicker: function() {
+               var $dp = this.inst.dpDiv,
+                       o = this._defaults,
+                       tp_inst = this,
+                       // Added by Peter Medeiros:
+                       // - Figure out what the hour/minute/second max should be based on the step values.
+                       // - Example: if stepMinute is 15, then minMax is 45.
+                       hourMax = parseInt((o.hourMax - ((o.hourMax - o.hourMin) % o.stepHour)) ,10),
+                       minMax  = parseInt((o.minuteMax - ((o.minuteMax - o.minuteMin) % o.stepMinute)) ,10),
+                       secMax  = parseInt((o.secondMax - ((o.secondMax - o.secondMin) % o.stepSecond)) ,10),
+                       millisecMax  = parseInt((o.millisecMax - ((o.millisecMax - o.millisecMin) % o.stepMillisec)) ,10),
+                       dp_id = this.inst.id.toString().replace(/([^A-Za-z0-9_])/g, '');
+
+               // Prevent displaying twice
+               //if ($dp.find("div#ui-timepicker-div-"+ dp_id).length === 0) {
+               if ($dp.find("div#ui-timepicker-div-"+ dp_id).length === 0 && o.showTimepicker) {
+                       var noDisplay = ' style="display:none;"',
+                               html =  '<div class="ui-timepicker-div" id="ui-timepicker-div-' + dp_id + '"><dl>' +
+                                               '<dt class="ui_tpicker_time_label" id="ui_tpicker_time_label_' + dp_id + '"' +
+                                               ((o.showTime) ? '' : noDisplay) + '>' + o.timeText + '</dt>' +
+                                               '<dd class="ui_tpicker_time" id="ui_tpicker_time_' + dp_id + '"' +
+                                               ((o.showTime) ? '' : noDisplay) + '></dd>' +
+                                               '<dt class="ui_tpicker_hour_label" id="ui_tpicker_hour_label_' + dp_id + '"' +
+                                               ((o.showHour) ? '' : noDisplay) + '>' + o.hourText + '</dt>',
+                               hourGridSize = 0,
+                               minuteGridSize = 0,
+                               secondGridSize = 0,
+                               millisecGridSize = 0,
+                               size = null;
+
+                       // Hours
+                       html += '<dd class="ui_tpicker_hour"><div id="ui_tpicker_hour_' + dp_id + '"' +
+                                               ((o.showHour) ? '' : noDisplay) + '></div>';
+                       if (o.showHour && o.hourGrid > 0) {
+                               html += '<div style="padding-left: 1px"><table class="ui-tpicker-grid-label"><tr>';
+
+                               for (var h = o.hourMin; h <= hourMax; h += parseInt(o.hourGrid,10)) {
+                                       hourGridSize++;
+                                       var tmph = (o.ampm && h > 12) ? h-12 : h;
+                                       if (tmph < 10) tmph = '0' + tmph;
+                                       if (o.ampm) {
+                                               if (h == 0) tmph = 12 +'a';
+                                               else if (h < 12) tmph += 'a';
+                                               else tmph += 'p';
+                                       }
+                                       html += '<td>' + tmph + '</td>';
+                               }
+
+                               html += '</tr></table></div>';
+                       }
+                       html += '</dd>';
+
+                       // Minutes
+                       html += '<dt class="ui_tpicker_minute_label" id="ui_tpicker_minute_label_' + dp_id + '"' +
+                                       ((o.showMinute) ? '' : noDisplay) + '>' + o.minuteText + '</dt>'+
+                                       '<dd class="ui_tpicker_minute"><div id="ui_tpicker_minute_' + dp_id + '"' +
+                                                       ((o.showMinute) ? '' : noDisplay) + '></div>';
+
+                       if (o.showMinute && o.minuteGrid > 0) {
+                               html += '<div style="padding-left: 1px"><table class="ui-tpicker-grid-label"><tr>';
+
+                               for (var m = o.minuteMin; m <= minMax; m += parseInt(o.minuteGrid,10)) {
+                                       minuteGridSize++;
+                                       html += '<td>' + ((m < 10) ? '0' : '') + m + '</td>';
+                               }
+
+                               html += '</tr></table></div>';
+                       }
+                       html += '</dd>';
+
+                       // Seconds
+                       html += '<dt class="ui_tpicker_second_label" id="ui_tpicker_second_label_' + dp_id + '"' +
+                                       ((o.showSecond) ? '' : noDisplay) + '>' + o.secondText + '</dt>'+
+                                       '<dd class="ui_tpicker_second"><div id="ui_tpicker_second_' + dp_id + '"'+
+                                                       ((o.showSecond) ? '' : noDisplay) + '></div>';
+
+                       if (o.showSecond && o.secondGrid > 0) {
+                               html += '<div style="padding-left: 1px"><table><tr>';
+
+                               for (var s = o.secondMin; s <= secMax; s += parseInt(o.secondGrid,10)) {
+                                       secondGridSize++;
+                                       html += '<td>' + ((s < 10) ? '0' : '') + s + '</td>';
+                               }
+
+                               html += '</tr></table></div>';
+                       }
+                       html += '</dd>';
+
+                       // Milliseconds
+                       html += '<dt class="ui_tpicker_millisec_label" id="ui_tpicker_millisec_label_' + dp_id + '"' +
+                                       ((o.showMillisec) ? '' : noDisplay) + '>' + o.millisecText + '</dt>'+
+                                       '<dd class="ui_tpicker_millisec"><div id="ui_tpicker_millisec_' + dp_id + '"'+
+                                                       ((o.showMillisec) ? '' : noDisplay) + '></div>';
+
+                       if (o.showMillisec && o.millisecGrid > 0) {
+                               html += '<div style="padding-left: 1px"><table><tr>';
+
+                               for (var l = o.millisecMin; l <= millisecMax; l += parseInt(o.millisecGrid,10)) {
+                                       millisecGridSize++;
+                                       html += '<td>' + ((l < 10) ? '0' : '') + l + '</td>';
+                               }
+
+                               html += '</tr></table></div>';
+                       }
+                       html += '</dd>';
+
+                       // Timezone
+                       html += '<dt class="ui_tpicker_timezone_label" id="ui_tpicker_timezone_label_' + dp_id + '"' +
+                                       ((o.showTimezone) ? '' : noDisplay) + '>' + o.timezoneText + '</dt>';
+                       html += '<dd class="ui_tpicker_timezone" id="ui_tpicker_timezone_' + dp_id + '"'        +
+                                                       ((o.showTimezone) ? '' : noDisplay) + '></dd>';
+
+                       html += '</dl></div>';
+                       $tp = $(html);
+
+                               // if we only want time picker...
+                       if (o.timeOnly === true) {
+                               $tp.prepend(
+                                       '<div class="ui-widget-header ui-helper-clearfix ui-corner-all">' +
+                                               '<div class="ui-datepicker-title">' + o.timeOnlyTitle + '</div>' +
+                                       '</div>');
+                               $dp.find('.ui-datepicker-header, .ui-datepicker-calendar').hide();
+                       }
+
+                       this.hour_slider = $tp.find('#ui_tpicker_hour_'+ dp_id).slider({
+                               orientation: "horizontal",
+                               value: this.hour,
+                               min: o.hourMin,
+                               max: hourMax,
+                               step: o.stepHour,
+                               slide: function(event, ui) {
+                                       tp_inst.hour_slider.slider( "option", "value", ui.value);
+                                       tp_inst._onTimeChange();
+                               }
+                       });
+
+
+                       // Updated by Peter Medeiros:
+                       // - Pass in Event and UI instance into slide function
+                       this.minute_slider = $tp.find('#ui_tpicker_minute_'+ dp_id).slider({
+                               orientation: "horizontal",
+                               value: this.minute,
+                               min: o.minuteMin,
+                               max: minMax,
+                               step: o.stepMinute,
+                               slide: function(event, ui) {
+                                       tp_inst.minute_slider.slider( "option", "value", ui.value);
+                                       tp_inst._onTimeChange();
+                               }
+                       });
+
+                       this.second_slider = $tp.find('#ui_tpicker_second_'+ dp_id).slider({
+                               orientation: "horizontal",
+                               value: this.second,
+                               min: o.secondMin,
+                               max: secMax,
+                               step: o.stepSecond,
+                               slide: function(event, ui) {
+                                       tp_inst.second_slider.slider( "option", "value", ui.value);
+                                       tp_inst._onTimeChange();
+                               }
+                       });
+
+                       this.millisec_slider = $tp.find('#ui_tpicker_millisec_'+ dp_id).slider({
+                               orientation: "horizontal",
+                               value: this.millisec,
+                               min: o.millisecMin,
+                               max: millisecMax,
+                               step: o.stepMillisec,
+                               slide: function(event, ui) {
+                                       tp_inst.millisec_slider.slider( "option", "value", ui.value);
+                                       tp_inst._onTimeChange();
+                               }
+                       });
+
+                       this.timezone_select = $tp.find('#ui_tpicker_timezone_'+ dp_id).append('<select></select>').find("select");
+                       $.fn.append.apply(this.timezone_select,
+                               $.map(o.timezoneList, function(val, idx) {
+                                       return $("<option />")
+                                               .val(typeof val == "object" ? val.value : val)
+                                               .text(typeof val == "object" ? val.label : val);
+                               })
+                       );
+                       this.timezone_select.val((typeof this.timezone != "undefined" && this.timezone != null && this.timezone != "") ? this.timezone : o.timezone);
+                       this.timezone_select.change(function() {
+                               tp_inst._onTimeChange();
+                       });
+
+                       // Add grid functionality
+                       if (o.showHour && o.hourGrid > 0) {
+                               size = 100 * hourGridSize * o.hourGrid / (hourMax - o.hourMin);
+
+                               $tp.find(".ui_tpicker_hour table").css({
+                                       width: size + "%",
+                                       marginLeft: (size / (-2 * hourGridSize)) + "%",
+                                       borderCollapse: 'collapse'
+                               }).find("td").each( function(index) {
+                                       $(this).click(function() {
+                                               var h = $(this).html();
+                                               if(o.ampm)      {
+                                                       var ap = h.substring(2).toLowerCase(),
+                                                               aph = parseInt(h.substring(0,2), 10);
+                                                       if (ap == 'a') {
+                                                               if (aph == 12) h = 0;
+                                                               else h = aph;
+                                                       } else if (aph == 12) h = 12;
+                                                       else h = aph + 12;
+                                               }
+                                               tp_inst.hour_slider.slider("option", "value", h);
+                                               tp_inst._onTimeChange();
+                                               tp_inst._onSelectHandler();
+                                       }).css({
+                                               cursor: 'pointer',
+                                               width: (100 / hourGridSize) + '%',
+                                               textAlign: 'center',
+                                               overflow: 'hidden'
+                                       });
+                               });
+                       }
+
+                       if (o.showMinute && o.minuteGrid > 0) {
+                               size = 100 * minuteGridSize * o.minuteGrid / (minMax - o.minuteMin);
+                               $tp.find(".ui_tpicker_minute table").css({
+                                       width: size + "%",
+                                       marginLeft: (size / (-2 * minuteGridSize)) + "%",
+                                       borderCollapse: 'collapse'
+                               }).find("td").each(function(index) {
+                                       $(this).click(function() {
+                                               tp_inst.minute_slider.slider("option", "value", $(this).html());
+                                               tp_inst._onTimeChange();
+                                               tp_inst._onSelectHandler();
+                                       }).css({
+                                               cursor: 'pointer',
+                                               width: (100 / minuteGridSize) + '%',
+                                               textAlign: 'center',
+                                               overflow: 'hidden'
+                                       });
+                               });
+                       }
+
+                       if (o.showSecond && o.secondGrid > 0) {
+                               $tp.find(".ui_tpicker_second table").css({
+                                       width: size + "%",
+                                       marginLeft: (size / (-2 * secondGridSize)) + "%",
+                                       borderCollapse: 'collapse'
+                               }).find("td").each(function(index) {
+                                       $(this).click(function() {
+                                               tp_inst.second_slider.slider("option", "value", $(this).html());
+                                               tp_inst._onTimeChange();
+                                               tp_inst._onSelectHandler();
+                                       }).css({
+                                               cursor: 'pointer',
+                                               width: (100 / secondGridSize) + '%',
+                                               textAlign: 'center',
+                                               overflow: 'hidden'
+                                       });
+                               });
+                       }
+
+                       if (o.showMillisec && o.millisecGrid > 0) {
+                               $tp.find(".ui_tpicker_millisec table").css({
+                                       width: size + "%",
+                                       marginLeft: (size / (-2 * millisecGridSize)) + "%",
+                                       borderCollapse: 'collapse'
+                               }).find("td").each(function(index) {
+                                       $(this).click(function() {
+                                               tp_inst.millisec_slider.slider("option", "value", $(this).html());
+                                               tp_inst._onTimeChange();
+                                               tp_inst._onSelectHandler();
+                                       }).css({
+                                               cursor: 'pointer',
+                                               width: (100 / millisecGridSize) + '%',
+                                               textAlign: 'center',
+                                               overflow: 'hidden'
+                                       });
+                               });
+                       }
+
+                       var $buttonPanel = $dp.find('.ui-datepicker-buttonpane');
+                       if ($buttonPanel.length) $buttonPanel.before($tp);
+                       else $dp.append($tp);
+
+                       this.$timeObj = $tp.find('#ui_tpicker_time_'+ dp_id);
+
+                       if (this.inst !== null) {
+                               var timeDefined = this.timeDefined;
+                               this._onTimeChange();
+                               this.timeDefined = timeDefined;
+                       }
+
+                       //Emulate datepicker onSelect behavior. Call on slidestop.
+                       var onSelectDelegate = function() {
+                               tp_inst._onSelectHandler();
+                       };
+                       this.hour_slider.bind('slidestop',onSelectDelegate);
+                       this.minute_slider.bind('slidestop',onSelectDelegate);
+                       this.second_slider.bind('slidestop',onSelectDelegate);
+                       this.millisec_slider.bind('slidestop',onSelectDelegate);
+
+                       // slideAccess integration: http://trentrichardson.com/2011/11/11/jquery-ui-sliders-and-touch-accessibility/
+                       if (this._defaults.addSliderAccess){
+                               var sliderAccessArgs = this._defaults.sliderAccessArgs;
+                               setTimeout(function(){ // fix for inline mode
+                                       if($tp.find('.ui-slider-access').length == 0){
+                                               $tp.find('.ui-slider:visible').sliderAccess(sliderAccessArgs);
+
+                                               // fix any grids since sliders are shorter
+                                               var sliderAccessWidth = $tp.find('.ui-slider-access:eq(0)').outerWidth(true);
+                                               if(sliderAccessWidth){
+                                                       $tp.find('table:visible').each(function(){
+                                                               var $g = $(this),
+                                                                       oldWidth = $g.outerWidth(),
+                                                                       oldMarginLeft = $g.css('marginLeft').toString().replace('%',''),
+                                                                       newWidth = oldWidth - sliderAccessWidth,
+                                                                       newMarginLeft = ((oldMarginLeft * newWidth)/oldWidth) + '%';
+
+                                                               $g.css({ width: newWidth, marginLeft: newMarginLeft });
+                                                       });
+                                               }
+                                       }
+                               },0);
+                       }
+                       // end slideAccess integration
+
+               }
+       },
+
+       //########################################################################
+       // This function tries to limit the ability to go outside the
+       // min/max date range
+       //########################################################################
+       _limitMinMaxDateTime: function(dp_inst, adjustSliders){
+               var o = this._defaults,
+                       dp_date = new Date(dp_inst.selectedYear, dp_inst.selectedMonth, dp_inst.selectedDay);
+
+               if(!this._defaults.showTimepicker) return; // No time so nothing to check here
+
+               if($.datepicker._get(dp_inst, 'minDateTime') !== null && $.datepicker._get(dp_inst, 'minDateTime') !== undefined && dp_date){
+                       var minDateTime = $.datepicker._get(dp_inst, 'minDateTime'),
+                               minDateTimeDate = new Date(minDateTime.getFullYear(), minDateTime.getMonth(), minDateTime.getDate(), 0, 0, 0, 0);
+
+                       if(this.hourMinOriginal === null || this.minuteMinOriginal === null || this.secondMinOriginal === null || this.millisecMinOriginal === null){
+                               this.hourMinOriginal = o.hourMin;
+                               this.minuteMinOriginal = o.minuteMin;
+                               this.secondMinOriginal = o.secondMin;
+                               this.millisecMinOriginal = o.millisecMin;
+                       }
+
+                       if(dp_inst.settings.timeOnly || minDateTimeDate.getTime() == dp_date.getTime()) {
+                               this._defaults.hourMin = minDateTime.getHours();
+                               if (this.hour <= this._defaults.hourMin) {
+                                       this.hour = this._defaults.hourMin;
+                                       this._defaults.minuteMin = minDateTime.getMinutes();
+                                       if (this.minute <= this._defaults.minuteMin) {
+                                               this.minute = this._defaults.minuteMin;
+                                               this._defaults.secondMin = minDateTime.getSeconds();
+                                       } else if (this.second <= this._defaults.secondMin){
+                                               this.second = this._defaults.secondMin;
+                                               this._defaults.millisecMin = minDateTime.getMilliseconds();
+                                       } else {
+                                               if(this.millisec < this._defaults.millisecMin)
+                                                       this.millisec = this._defaults.millisecMin;
+                                               this._defaults.millisecMin = this.millisecMinOriginal;
+                                       }
+                               } else {
+                                       this._defaults.minuteMin = this.minuteMinOriginal;
+                                       this._defaults.secondMin = this.secondMinOriginal;
+                                       this._defaults.millisecMin = this.millisecMinOriginal;
+                               }
+                       }else{
+                               this._defaults.hourMin = this.hourMinOriginal;
+                               this._defaults.minuteMin = this.minuteMinOriginal;
+                               this._defaults.secondMin = this.secondMinOriginal;
+                               this._defaults.millisecMin = this.millisecMinOriginal;
+                       }
+               }
+
+               if($.datepicker._get(dp_inst, 'maxDateTime') !== null && $.datepicker._get(dp_inst, 'maxDateTime') !== undefined && dp_date){
+                       var maxDateTime = $.datepicker._get(dp_inst, 'maxDateTime'),
+                               maxDateTimeDate = new Date(maxDateTime.getFullYear(), maxDateTime.getMonth(), maxDateTime.getDate(), 0, 0, 0, 0);
+
+                       if(this.hourMaxOriginal === null || this.minuteMaxOriginal === null || this.secondMaxOriginal === null){
+                               this.hourMaxOriginal = o.hourMax;
+                               this.minuteMaxOriginal = o.minuteMax;
+                               this.secondMaxOriginal = o.secondMax;
+                               this.millisecMaxOriginal = o.millisecMax;
+                       }
+
+                       if(dp_inst.settings.timeOnly || maxDateTimeDate.getTime() == dp_date.getTime()){
+                               this._defaults.hourMax = maxDateTime.getHours();
+                               if (this.hour >= this._defaults.hourMax) {
+                                       this.hour = this._defaults.hourMax;
+                                       this._defaults.minuteMax = maxDateTime.getMinutes();
+                                       if (this.minute >= this._defaults.minuteMax) {
+                                               this.minute = this._defaults.minuteMax;
+                                               this._defaults.secondMax = maxDateTime.getSeconds();
+                                       } else if (this.second >= this._defaults.secondMax) {
+                                               this.second = this._defaults.secondMax;
+                                               this._defaults.millisecMax = maxDateTime.getMilliseconds();
+                                       } else {
+                                               if(this.millisec > this._defaults.millisecMax) this.millisec = this._defaults.millisecMax;
+                                               this._defaults.millisecMax = this.millisecMaxOriginal;
+                                       }
+                               } else {
+                                       this._defaults.minuteMax = this.minuteMaxOriginal;
+                                       this._defaults.secondMax = this.secondMaxOriginal;
+                                       this._defaults.millisecMax = this.millisecMaxOriginal;
+                               }
+                       }else{
+                               this._defaults.hourMax = this.hourMaxOriginal;
+                               this._defaults.minuteMax = this.minuteMaxOriginal;
+                               this._defaults.secondMax = this.secondMaxOriginal;
+                               this._defaults.millisecMax = this.millisecMaxOriginal;
+                       }
+               }
+
+               if(adjustSliders !== undefined && adjustSliders === true){
+                       var hourMax = parseInt((this._defaults.hourMax - ((this._defaults.hourMax - this._defaults.hourMin) % this._defaults.stepHour)) ,10),
+                minMax  = parseInt((this._defaults.minuteMax - ((this._defaults.minuteMax - this._defaults.minuteMin) % this._defaults.stepMinute)) ,10),
+                secMax  = parseInt((this._defaults.secondMax - ((this._defaults.secondMax - this._defaults.secondMin) % this._defaults.stepSecond)) ,10),
+                               millisecMax  = parseInt((this._defaults.millisecMax - ((this._defaults.millisecMax - this._defaults.millisecMin) % this._defaults.stepMillisec)) ,10);
+
+                       if(this.hour_slider)
+                               this.hour_slider.slider("option", { min: this._defaults.hourMin, max: hourMax }).slider('value', this.hour);
+                       if(this.minute_slider)
+                               this.minute_slider.slider("option", { min: this._defaults.minuteMin, max: minMax }).slider('value', this.minute);
+                       if(this.second_slider)
+                               this.second_slider.slider("option", { min: this._defaults.secondMin, max: secMax }).slider('value', this.second);
+                       if(this.millisec_slider)
+                               this.millisec_slider.slider("option", { min: this._defaults.millisecMin, max: millisecMax }).slider('value', this.millisec);
+               }
+
+       },
+
+
+       //########################################################################
+       // when a slider moves, set the internal time...
+       // on time change is also called when the time is updated in the text field
+       //########################################################################
+       _onTimeChange: function() {
+               var hour   = (this.hour_slider) ? this.hour_slider.slider('value') : false,
+                       minute = (this.minute_slider) ? this.minute_slider.slider('value') : false,
+                       second = (this.second_slider) ? this.second_slider.slider('value') : false,
+                       millisec = (this.millisec_slider) ? this.millisec_slider.slider('value') : false,
+                       timezone = (this.timezone_select) ? this.timezone_select.val() : false,
+                       o = this._defaults;
+
+               if (typeof(hour) == 'object') hour = false;
+               if (typeof(minute) == 'object') minute = false;
+               if (typeof(second) == 'object') second = false;
+               if (typeof(millisec) == 'object') millisec = false;
+               if (typeof(timezone) == 'object') timezone = false;
+
+               if (hour !== false) hour = parseInt(hour,10);
+               if (minute !== false) minute = parseInt(minute,10);
+               if (second !== false) second = parseInt(second,10);
+               if (millisec !== false) millisec = parseInt(millisec,10);
+
+               var ampm = o[hour < 12 ? 'amNames' : 'pmNames'][0];
+
+               // If the update was done in the input field, the input field should not be updated.
+               // If the update was done using the sliders, update the input field.
+               var hasChanged = (hour != this.hour || minute != this.minute
+                               || second != this.second || millisec != this.millisec
+                               || (this.ampm.length > 0
+                                   && (hour < 12) != ($.inArray(this.ampm.toUpperCase(), this.amNames) !== -1))
+                               || timezone != this.timezone);
+
+               if (hasChanged) {
+
+                       if (hour !== false)this.hour = hour;
+                       if (minute !== false) this.minute = minute;
+                       if (second !== false) this.second = second;
+                       if (millisec !== false) this.millisec = millisec;
+                       if (timezone !== false) this.timezone = timezone;
+
+                       if (!this.inst) this.inst = $.datepicker._getInst(this.$input[0]);
+
+                       this._limitMinMaxDateTime(this.inst, true);
+               }
+               if (o.ampm) this.ampm = ampm;
+
+               //this._formatTime();
+               this.formattedTime = $.datepicker.formatTime(this._defaults.timeFormat, this, this._defaults);
+               if (this.$timeObj) this.$timeObj.text(this.formattedTime + o.timeSuffix);
+               this.timeDefined = true;
+               if (hasChanged) this._updateDateTime();
+       },
+
+       //########################################################################
+       // call custom onSelect.
+       // bind to sliders slidestop, and grid click.
+       //########################################################################
+       _onSelectHandler: function() {
+               var onSelect = this._defaults.onSelect;
+               var inputEl = this.$input ? this.$input[0] : null;
+               if (onSelect && inputEl) {
+                       onSelect.apply(inputEl, [this.formattedDateTime, this]);
+               }
+       },
+
+       //########################################################################
+       // left for any backwards compatibility
+       //########################################################################
+       _formatTime: function(time, format) {
+               time = time || { hour: this.hour, minute: this.minute, second: this.second, millisec: this.millisec, ampm: this.ampm, timezone: this.timezone };
+               var tmptime = (format || this._defaults.timeFormat).toString();
+
+               tmptime = $.datepicker.formatTime(tmptime, time, this._defaults);
+
+               if (arguments.length) return tmptime;
+               else this.formattedTime = tmptime;
+       },
+
+       //########################################################################
+       // update our input with the new date time..
+       //########################################################################
+       _updateDateTime: function(dp_inst) {
+               dp_inst = this.inst || dp_inst;
+               var dt = $.datepicker._daylightSavingAdjust(new Date(dp_inst.selectedYear, dp_inst.selectedMonth, dp_inst.selectedDay)),
+                       dateFmt = $.datepicker._get(dp_inst, 'dateFormat'),
+                       formatCfg = $.datepicker._getFormatConfig(dp_inst),
+                       timeAvailable = dt !== null && this.timeDefined;
+               this.formattedDate = $.datepicker.formatDate(dateFmt, (dt === null ? new Date() : dt), formatCfg);
+               var formattedDateTime = this.formattedDate;
+               if (dp_inst.lastVal !== undefined && (dp_inst.lastVal.length > 0 && this.$input.val().length === 0))
+                       return;
+
+               if (this._defaults.timeOnly === true) {
+                       formattedDateTime = this.formattedTime;
+               } else if (this._defaults.timeOnly !== true && (this._defaults.alwaysSetTime || timeAvailable)) {
+                       formattedDateTime += this._defaults.separator + this.formattedTime + this._defaults.timeSuffix;
+               }
+
+               this.formattedDateTime = formattedDateTime;
+
+               if(!this._defaults.showTimepicker) {
+                       this.$input.val(this.formattedDate);
+               } else if (this.$altInput && this._defaults.altFieldTimeOnly === true) {
+                       this.$altInput.val(this.formattedTime);
+                       this.$input.val(this.formattedDate);
+               } else if(this.$altInput) {
+                       this.$altInput.val(formattedDateTime);
+                       this.$input.val(formattedDateTime);
+               } else {
+                       this.$input.val(formattedDateTime);
+               }
+
+               this.$input.trigger("change");
+       }
+
+});
+
+$.fn.extend({
+       //########################################################################
+       // shorthand just to use timepicker..
+       //########################################################################
+       timepicker: function(o) {
+               o = o || {};
+               var tmp_args = arguments;
+
+               if (typeof o == 'object') tmp_args[0] = $.extend(o, { timeOnly: true });
+
+               return $(this).each(function() {
+                       $.fn.datetimepicker.apply($(this), tmp_args);
+               });
+       },
+
+       //########################################################################
+       // extend timepicker to datepicker
+       //########################################################################
+       datetimepicker: function(o) {
+               o = o || {};
+               tmp_args = arguments;
+
+               if (typeof(o) == 'string'){
+                       if(o == 'getDate')
+                               return $.fn.datepicker.apply($(this[0]), tmp_args);
+                       else
+                               return this.each(function() {
+                                       var $t = $(this);
+                                       $t.datepicker.apply($t, tmp_args);
+                               });
+               }
+               else
+                       return this.each(function() {
+                               var $t = $(this);
+                               $t.datepicker($.timepicker._newInst($t, o)._defaults);
+                       });
+       }
+});
+
+//########################################################################
+// format the time all pretty...
+// format = string format of the time
+// time = a {}, not a Date() for timezones
+// options = essentially the regional[].. amNames, pmNames, ampm
+//########################################################################
+$.datepicker.formatTime = function(format, time, options) {
+       options = options || {};
+       options = $.extend($.timepicker._defaults, options);
+       time = $.extend({hour:0, minute:0, second:0, millisec:0, timezone:'+0000'}, time);
+
+       var tmptime = format;
+       var ampmName = options['amNames'][0];
+
+       var hour = parseInt(time.hour, 10);
+       if (options.ampm) {
+               if (hour > 11){
+                       ampmName = options['pmNames'][0];
+                       if(hour > 12)
+                               hour = hour % 12;
+               }
+               if (hour === 0)
+                       hour = 12;
+       }
+       tmptime = tmptime.replace(/(?:hh?|mm?|ss?|[tT]{1,2}|[lz])/g, function(match) {
+               switch (match.toLowerCase()) {
+                       case 'hh': return ('0' + hour).slice(-2);
+                       case 'h':  return hour;
+                       case 'mm': return ('0' + time.minute).slice(-2);
+                       case 'm':  return time.minute;
+                       case 'ss': return ('0' + time.second).slice(-2);
+                       case 's':  return time.second;
+                       case 'l':  return ('00' + time.millisec).slice(-3);
+                       case 'z':  return time.timezone;
+                       case 't': case 'tt':
+                               if (options.ampm) {
+                                       if (match.length == 1)
+                                               ampmName = ampmName.charAt(0);
+                                       return match.charAt(0) == 'T' ? ampmName.toUpperCase() : ampmName.toLowerCase();
+                               }
+                               return '';
+               }
+       });
+
+       tmptime = $.trim(tmptime);
+       return tmptime;
+};
+
+//########################################################################
+// the bad hack :/ override datepicker so it doesnt close on select
+// inspired: http://stackoverflow.com/questions/1252512/jquery-datepicker-prevent-closing-picker-when-clicking-a-date/1762378#1762378
+//########################################################################
+$.datepicker._base_selectDate = $.datepicker._selectDate;
+$.datepicker._selectDate = function (id, dateStr) {
+       var inst = this._getInst($(id)[0]),
+               tp_inst = this._get(inst, 'timepicker');
+
+       if (tp_inst) {
+               tp_inst._limitMinMaxDateTime(inst, true);
+               inst.inline = inst.stay_open = true;
+               //This way the onSelect handler called from calendarpicker get the full dateTime
+               this._base_selectDate(id, dateStr);
+               inst.inline = inst.stay_open = false;
+               this._notifyChange(inst);
+               this._updateDatepicker(inst);
+       }
+       else this._base_selectDate(id, dateStr);
+};
+
+//#############################################################################################
+// second bad hack :/ override datepicker so it triggers an event when changing the input field
+// and does not redraw the datepicker on every selectDate event
+//#############################################################################################
+$.datepicker._base_updateDatepicker = $.datepicker._updateDatepicker;
+$.datepicker._updateDatepicker = function(inst) {
+
+       // don't popup the datepicker if there is another instance already opened
+       var input = inst.input[0];
+       if($.datepicker._curInst &&
+          $.datepicker._curInst != inst &&
+          $.datepicker._datepickerShowing &&
+          $.datepicker._lastInput != input) {
+               return;
+       }
+
+       if (typeof(inst.stay_open) !== 'boolean' || inst.stay_open === false) {
+
+               this._base_updateDatepicker(inst);
+
+               // Reload the time control when changing something in the input text field.
+               var tp_inst = this._get(inst, 'timepicker');
+               if(tp_inst) tp_inst._addTimePicker(inst);
+       }
+};
+
+//#######################################################################################
+// third bad hack :/ override datepicker so it allows spaces and colon in the input field
+//#######################################################################################
+$.datepicker._base_doKeyPress = $.datepicker._doKeyPress;
+$.datepicker._doKeyPress = function(event) {
+       var inst = $.datepicker._getInst(event.target),
+               tp_inst = $.datepicker._get(inst, 'timepicker');
+
+       if (tp_inst) {
+               if ($.datepicker._get(inst, 'constrainInput')) {
+                       var ampm = tp_inst._defaults.ampm,
+                               dateChars = $.datepicker._possibleChars($.datepicker._get(inst, 'dateFormat')),
+                               datetimeChars = tp_inst._defaults.timeFormat.toString()
+                                                               .replace(/[hms]/g, '')
+                                                               .replace(/TT/g, ampm ? 'APM' : '')
+                                                               .replace(/Tt/g, ampm ? 'AaPpMm' : '')
+                                                               .replace(/tT/g, ampm ? 'AaPpMm' : '')
+                                                               .replace(/T/g, ampm ? 'AP' : '')
+                                                               .replace(/tt/g, ampm ? 'apm' : '')
+                                                               .replace(/t/g, ampm ? 'ap' : '') +
+                                                               " " +
+                                                               tp_inst._defaults.separator +
+                                                               tp_inst._defaults.timeSuffix +
+                                                               (tp_inst._defaults.showTimezone ? tp_inst._defaults.timezoneList.join('') : '') +
+                                                               (tp_inst._defaults.amNames.join('')) +
+                                                               (tp_inst._defaults.pmNames.join('')) +
+                                                               dateChars,
+                               chr = String.fromCharCode(event.charCode === undefined ? event.keyCode : event.charCode);
+                       return event.ctrlKey || (chr < ' ' || !dateChars || datetimeChars.indexOf(chr) > -1);
+               }
+       }
+
+       return $.datepicker._base_doKeyPress(event);
+};
+
+//#######################################################################################
+// Override key up event to sync manual input changes.
+//#######################################################################################
+$.datepicker._base_doKeyUp = $.datepicker._doKeyUp;
+$.datepicker._doKeyUp = function (event) {
+       var inst = $.datepicker._getInst(event.target),
+               tp_inst = $.datepicker._get(inst, 'timepicker');
+
+       if (tp_inst) {
+               if (tp_inst._defaults.timeOnly && (inst.input.val() != inst.lastVal)) {
+                       try {
+                               $.datepicker._updateDatepicker(inst);
+                       }
+                       catch (err) {
+                               $.datepicker.log(err);
+                       }
+               }
+       }
+
+       return $.datepicker._base_doKeyUp(event);
+};
+
+//#######################################################################################
+// override "Today" button to also grab the time.
+//#######################################################################################
+$.datepicker._base_gotoToday = $.datepicker._gotoToday;
+$.datepicker._gotoToday = function(id) {
+       var inst = this._getInst($(id)[0]),
+               $dp = inst.dpDiv;
+       this._base_gotoToday(id);
+       var now = new Date();
+       var tp_inst = this._get(inst, 'timepicker');
+       if (tp_inst && tp_inst._defaults.showTimezone && tp_inst.timezone_select) {
+               var tzoffset = now.getTimezoneOffset(); // If +0100, returns -60
+               var tzsign = tzoffset > 0 ? '-' : '+';
+               tzoffset = Math.abs(tzoffset);
+               var tzmin = tzoffset % 60;
+               tzoffset = tzsign + ('0' + (tzoffset - tzmin) / 60).slice(-2) + ('0' + tzmin).slice(-2);
+               if (tp_inst._defaults.timezoneIso8609)
+                       tzoffset = tzoffset.substring(0, 3) + ':' + tzoffset.substring(3);
+               tp_inst.timezone_select.val(tzoffset);
+       }
+       this._setTime(inst, now);
+       $( '.ui-datepicker-today', $dp).click();
+};
+
+//#######################################################################################
+// Disable & enable the Time in the datetimepicker
+//#######################################################################################
+$.datepicker._disableTimepickerDatepicker = function(target, date, withDate) {
+       var inst = this._getInst(target),
+       tp_inst = this._get(inst, 'timepicker');
+       $(target).datepicker('getDate'); // Init selected[Year|Month|Day]
+       if (tp_inst) {
+               tp_inst._defaults.showTimepicker = false;
+               tp_inst._updateDateTime(inst);
+       }
+};
+
+$.datepicker._enableTimepickerDatepicker = function(target, date, withDate) {
+       var inst = this._getInst(target),
+       tp_inst = this._get(inst, 'timepicker');
+       $(target).datepicker('getDate'); // Init selected[Year|Month|Day]
+       if (tp_inst) {
+               tp_inst._defaults.showTimepicker = true;
+               tp_inst._addTimePicker(inst); // Could be disabled on page load
+               tp_inst._updateDateTime(inst);
+       }
+};
+
+//#######################################################################################
+// Create our own set time function
+//#######################################################################################
+$.datepicker._setTime = function(inst, date) {
+       var tp_inst = this._get(inst, 'timepicker');
+       if (tp_inst) {
+               var defaults = tp_inst._defaults,
+                       // calling _setTime with no date sets time to defaults
+                       hour = date ? date.getHours() : defaults.hour,
+                       minute = date ? date.getMinutes() : defaults.minute,
+                       second = date ? date.getSeconds() : defaults.second,
+                       millisec = date ? date.getMilliseconds() : defaults.millisec;
+
+               //check if within min/max times..
+               if ((hour < defaults.hourMin || hour > defaults.hourMax) || (minute < defaults.minuteMin || minute > defaults.minuteMax) || (second < defaults.secondMin || second > defaults.secondMax) || (millisec < defaults.millisecMin || millisec > defaults.millisecMax)) {
+                       hour = defaults.hourMin;
+                       minute = defaults.minuteMin;
+                       second = defaults.secondMin;
+                       millisec = defaults.millisecMin;
+               }
+
+               tp_inst.hour = hour;
+               tp_inst.minute = minute;
+               tp_inst.second = second;
+               tp_inst.millisec = millisec;
+
+               if (tp_inst.hour_slider) tp_inst.hour_slider.slider('value', hour);
+               if (tp_inst.minute_slider) tp_inst.minute_slider.slider('value', minute);
+               if (tp_inst.second_slider) tp_inst.second_slider.slider('value', second);
+               if (tp_inst.millisec_slider) tp_inst.millisec_slider.slider('value', millisec);
+
+               tp_inst._onTimeChange();
+               tp_inst._updateDateTime(inst);
+       }
+};
+
+//#######################################################################################
+// Create new public method to set only time, callable as $().datepicker('setTime', date)
+//#######################################################################################
+$.datepicker._setTimeDatepicker = function(target, date, withDate) {
+       var inst = this._getInst(target),
+               tp_inst = this._get(inst, 'timepicker');
+
+       if (tp_inst) {
+               this._setDateFromField(inst);
+               var tp_date;
+               if (date) {
+                       if (typeof date == "string") {
+                               tp_inst._parseTime(date, withDate);
+                               tp_date = new Date();
+                               tp_date.setHours(tp_inst.hour, tp_inst.minute, tp_inst.second, tp_inst.millisec);
+                       }
+                       else tp_date = new Date(date.getTime());
+                       if (tp_date.toString() == 'Invalid Date') tp_date = undefined;
+                       this._setTime(inst, tp_date);
+               }
+       }
+
+};
+
+//#######################################################################################
+// override setDate() to allow setting time too within Date object
+//#######################################################################################
+$.datepicker._base_setDateDatepicker = $.datepicker._setDateDatepicker;
+$.datepicker._setDateDatepicker = function(target, date) {
+       var inst = this._getInst(target),
+       tp_date = (date instanceof Date) ? new Date(date.getTime()) : date;
+
+       this._updateDatepicker(inst);
+       this._base_setDateDatepicker.apply(this, arguments);
+       this._setTimeDatepicker(target, tp_date, true);
+};
+
+//#######################################################################################
+// override getDate() to allow getting time too within Date object
+//#######################################################################################
+$.datepicker._base_getDateDatepicker = $.datepicker._getDateDatepicker;
+$.datepicker._getDateDatepicker = function(target, noDefault) {
+       var inst = this._getInst(target),
+               tp_inst = this._get(inst, 'timepicker');
+
+       if (tp_inst) {
+               this._setDateFromField(inst, noDefault);
+               var date = this._getDate(inst);
+               if (date && tp_inst._parseTime($(target).val(), tp_inst.timeOnly)) date.setHours(tp_inst.hour, tp_inst.minute, tp_inst.second, tp_inst.millisec);
+               return date;
+       }
+       return this._base_getDateDatepicker(target, noDefault);
+};
+
+//#######################################################################################
+// override parseDate() because UI 1.8.14 throws an error about "Extra characters"
+// An option in datapicker to ignore extra format characters would be nicer.
+//#######################################################################################
+$.datepicker._base_parseDate = $.datepicker.parseDate;
+$.datepicker.parseDate = function(format, value, settings) {
+       var date;
+       try {
+               date = this._base_parseDate(format, value, settings);
+       } catch (err) {
+               if (err.indexOf(":") >= 0) {
+                       // Hack!  The error message ends with a colon, a space, and
+                       // the "extra" characters.  We rely on that instead of
+                       // attempting to perfectly reproduce the parsing algorithm.
+                       date = this._base_parseDate(format, value.substring(0,value.length-(err.length-err.indexOf(':')-2)), settings);
+               } else {
+                       // The underlying error was not related to the time
+                       throw err;
+               }
+       }
+       return date;
+};
+
+//#######################################################################################
+// override formatDate to set date with time to the input
+//#######################################################################################
+$.datepicker._base_formatDate = $.datepicker._formatDate;
+$.datepicker._formatDate = function(inst, day, month, year){
+       var tp_inst = this._get(inst, 'timepicker');
+       if(tp_inst) {
+               tp_inst._updateDateTime(inst);
+               return tp_inst.$input.val();
+       }
+       return this._base_formatDate(inst);
+};
+
+//#######################################################################################
+// override options setter to add time to maxDate(Time) and minDate(Time). MaxDate
+//#######################################################################################
+$.datepicker._base_optionDatepicker = $.datepicker._optionDatepicker;
+$.datepicker._optionDatepicker = function(target, name, value) {
+       var inst = this._getInst(target),
+               tp_inst = this._get(inst, 'timepicker');
+       if (tp_inst) {
+               var min = null, max = null, onselect = null;
+               if (typeof name == 'string') { // if min/max was set with the string
+                       if (name === 'minDate' || name === 'minDateTime' )
+                               min = value;
+                       else if (name === 'maxDate' || name === 'maxDateTime')
+                               max = value;
+                       else if (name === 'onSelect')
+                               onselect = value;
+               } else if (typeof name == 'object') { //if min/max was set with the JSON
+                       if (name.minDate)
+                               min = name.minDate;
+                       else if (name.minDateTime)
+                               min = name.minDateTime;
+                       else if (name.maxDate)
+                               max = name.maxDate;
+                       else if (name.maxDateTime)
+                               max = name.maxDateTime;
+               }
+               if(min) { //if min was set
+                       if (min == 0)
+                               min = new Date();
+                       else
+                               min = new Date(min);
+
+                       tp_inst._defaults.minDate = min;
+                       tp_inst._defaults.minDateTime = min;
+               } else if (max) { //if max was set
+                       if(max==0)
+                               max=new Date();
+                       else
+                               max= new Date(max);
+                       tp_inst._defaults.maxDate = max;
+                       tp_inst._defaults.maxDateTime = max;
+               } else if (onselect)
+                       tp_inst._defaults.onSelect = onselect;
+       }
+       if (value === undefined)
+               return this._base_optionDatepicker(target, name);
+       return this._base_optionDatepicker(target, name, value);
+};
+
+//#######################################################################################
+// jQuery extend now ignores nulls!
+//#######################################################################################
+function extendRemove(target, props) {
+       $.extend(target, props);
+       for (var name in props)
+               if (props[name] === null || props[name] === undefined)
+                       target[name] = props[name];
+       return target;
+};
+
+$.timepicker = new Timepicker(); // singleton instance
+$.timepicker.version = "1.0.0";
+
+})(jQuery);
diff --git a/rt/share/html/NoAuth/js/ui.timepickr.js b/rt/share/html/NoAuth/js/ui.timepickr.js
deleted file mode 100644 (file)
index 3b2040a..0000000
+++ /dev/null
@@ -1,941 +0,0 @@
-/*
-  jQuery utils - @VERSION
-  http://code.google.com/p/jquery-utils/
-
-  (c) Maxime Haineault <haineault@gmail.com> 
-  http://haineault.com
-
-  MIT License (http://www.opensource.org/licenses/mit-license.php
-
-*/
-
-(function($){
-     $.extend($.expr[':'], {
-        // case insensitive version of :contains
-        icontains: function(a,i,m){return (a.textContent||a.innerText||jQuery(a).text()||"").toLowerCase().indexOf(m[3].toLowerCase())>=0;}
-    });
-
-    $.iterators = {
-        getText:  function() { return $(this).text(); },
-        parseInt: function(v){ return parseInt(v, 10); }
-    };
-
-       $.extend({ 
-
-        // Returns a range object
-        // Author: Matthias Miller
-        // Site:   http://blog.outofhanwell.com/2006/03/29/javascript-range-function/
-        range:  function() {
-            if (!arguments.length) { return []; }
-            var min, max, step;
-            if (arguments.length == 1) {
-                min  = 0;
-                max  = arguments[0]-1;
-                step = 1;
-            }
-            else {
-                // default step to 1 if it's zero or undefined
-                min  = arguments[0];
-                max  = arguments[1]-1;
-                step = arguments[2] || 1;
-            }
-            // convert negative steps to positive and reverse min/max
-            if (step < 0 && min >= max) {
-                step *= -1;
-                var tmp = min;
-                min = max;
-                max = tmp;
-                min += ((max-min) % step);
-            }
-            var a = [];
-            for (var i = min; i <= max; i += step) { a.push(i); }
-            return a;
-        },
-
-        // Taken from ui.core.js. 
-        // Why are you keeping this gem for yourself guys ? :|
-        keyCode: {
-            BACKSPACE: 8, CAPS_LOCK: 20, COMMA: 188, CONTROL: 17, DELETE: 46, DOWN: 40,
-            END: 35, ENTER: 13, ESCAPE: 27, HOME: 36, INSERT:  45, LEFT: 37,
-            NUMPAD_ADD: 107, NUMPAD_DECIMAL: 110, NUMPAD_DIVIDE: 111, NUMPAD_ENTER: 108, 
-            NUMPAD_MULTIPLY: 106, NUMPAD_SUBTRACT: 109, PAGE_DOWN: 34, PAGE_UP: 33, 
-            PERIOD: 190, RIGHT: 39, SHIFT: 16, SPACE: 32, TAB: 9, UP: 38
-        },
-        
-        // Takes a keyboard event and return true if the keycode match the specified keycode
-        keyIs: function(k, e) {
-            return parseInt($.keyCode[k.toUpperCase()], 10) == parseInt((typeof(e) == 'number' )? e: e.keyCode, 10);
-        },
-        
-        // Returns the key of an array
-        keys: function(arr) {
-            var o = [];
-            for (k in arr) { o.push(k); }
-            return o;
-        },
-
-        // Redirect to a specified url
-        redirect: function(url) {
-            window.location.href = url;
-            return url;
-        },
-
-        // Stop event shorthand
-        stop: function(e, preventDefault, stopPropagation) {
-            if (preventDefault)  { e.preventDefault(); }
-            if (stopPropagation) { e.stopPropagation(); }
-            return preventDefault && false || true;
-        },
-
-        // Returns the basename of a path
-        basename: function(path) {
-            var t = path.split('/');
-            return t[t.length] === '' && s || t.slice(0, t.length).join('/');
-        },
-
-        // Returns the filename of a path
-        filename: function(path) {
-            return path.split('/').pop();
-        }, 
-
-        // Returns a formated file size
-        filesizeformat: function(bytes, suffixes){
-            var b = parseInt(bytes, 10);
-            var s = suffixes || ['byte', 'bytes', 'KB', 'MB', 'GB'];
-            if (isNaN(b) || b === 0) { return '0 ' + s[0]; }
-            if (b == 1)              { return '1 ' + s[0]; }
-            if (b < 1024)            { return  b.toFixed(2) + ' ' + s[1]; }
-            if (b < 1048576)         { return (b / 1024).toFixed(2) + ' ' + s[2]; }
-            if (b < 1073741824)      { return (b / 1048576).toFixed(2) + ' '+ s[3]; }
-            else                     { return (b / 1073741824).toFixed(2) + ' '+ s[4]; }
-        },
-
-        fileExtension: function(s) {
-            var tokens = s.split('.');
-            return tokens[tokens.length-1] || false;
-        },
-        
-        // Returns true if an object is a String
-        isString: function(o) {
-            return typeof(o) == 'string' && true || false;
-        },
-        
-        // Returns true if an object is a RegExp
-               isRegExp: function(o) {
-                       return o && o.constructor.toString().indexOf('RegExp()') != -1 || false;
-               },
-
-        isObject: function(o) {
-            return (typeof(o) == 'object');
-        },
-        
-        // Convert input to currency (two decimal fixed number)
-               toCurrency: function(i) {
-                       i = parseFloat(i, 10).toFixed(2);
-                       return (i=='NaN') ? '0.00' : i;
-               },
-
-        /*-------------------------------------------------------------------- 
-         * javascript method: "pxToEm"
-         * by:
-           Scott Jehl (scott@filamentgroup.com) 
-           Maggie Wachs (maggie@filamentgroup.com)
-           http://www.filamentgroup.com
-         *
-         * Copyright (c) 2008 Filament Group
-         * Dual licensed under the MIT (filamentgroup.com/examples/mit-license.txt) and GPL (filamentgroup.com/examples/gpl-license.txt) licenses.
-         *
-         * Description: pxToEm converts a pixel value to ems depending on inherited font size.  
-         * Article: http://www.filamentgroup.com/lab/retaining_scalable_interfaces_with_pixel_to_em_conversion/
-         * Demo: http://www.filamentgroup.com/examples/pxToEm/         
-         *                                                     
-         * Options:                                                                    
-                scope: string or jQuery selector for font-size scoping
-                reverse: Boolean, true reverses the conversion to em-px
-         * Dependencies: jQuery library                                                  
-         * Usage Example: myPixelValue.pxToEm(); or myPixelValue.pxToEm({'scope':'#navigation', reverse: true});
-         *
-         * Version: 2.1, 18.12.2008
-         * Changelog:
-         *             08.02.2007 initial Version 1.0
-         *             08.01.2008 - fixed font-size calculation for IE
-         *             18.12.2008 - removed native object prototyping to stay in jQuery's spirit, jsLinted (Maxime Haineault <haineault@gmail.com>)
-        --------------------------------------------------------------------*/
-
-        pxToEm: function(i, settings){
-            //set defaults
-            settings = jQuery.extend({
-                scope: 'body',
-                reverse: false
-            }, settings);
-            
-            var pxVal = (i === '') ? 0 : parseFloat(i);
-            var scopeVal;
-            var getWindowWidth = function(){
-                var de = document.documentElement;
-                return self.innerWidth || (de && de.clientWidth) || document.body.clientWidth;
-            }; 
-            
-            /* When a percentage-based font-size is set on the body, IE returns that percent of the window width as the font-size. 
-                For example, if the body font-size is 62.5% and the window width is 1000px, IE will return 625px as the font-size.     
-                When this happens, we calculate the correct body font-size (%) and multiply it by 16 (the standard browser font size) 
-                to get an accurate em value. */
-                        
-            if (settings.scope == 'body' && $.browser.msie && (parseFloat($('body').css('font-size')) / getWindowWidth()).toFixed(1) > 0.0) {
-                var calcFontSize = function(){         
-                    return (parseFloat($('body').css('font-size'))/getWindowWidth()).toFixed(3) * 16;
-                };
-                scopeVal = calcFontSize();
-            }
-            else { scopeVal = parseFloat(jQuery(settings.scope).css("font-size")); }
-                    
-            var result = (settings.reverse === true) ? (pxVal * scopeVal).toFixed(2) + 'px' : (pxVal / scopeVal).toFixed(2) + 'em';
-            return result;
-        }
-       });
-
-       $.extend($.fn, { 
-        type: function() {
-            try { return $(this).get(0).nodeName.toLowerCase(); }
-            catch(e) { return false; }
-        },
-        // Select a text range in a textarea
-        selectRange: function(start, end){
-            // use only the first one since only one input can be focused
-            if ($(this).get(0).createTextRange) {
-                var range = $(this).get(0).createTextRange();
-                range.collapse(true);
-                range.moveEnd('character',   end);
-                range.moveStart('character', start);
-                range.select();
-            }
-            else if ($(this).get(0).setSelectionRange) {
-                $(this).bind('focus', function(e){
-                    e.preventDefault();
-                }).get(0).setSelectionRange(start, end);
-            }
-            return $(this);
-        },
-
-        /*-------------------------------------------------------------------- 
-         * JQuery Plugin: "EqualHeights"
-         * by: Scott Jehl, Todd Parker, Maggie Costello Wachs (http://www.filamentgroup.com)
-         *
-         * Copyright (c) 2008 Filament Group
-         * Licensed under GPL (http://www.opensource.org/licenses/gpl-license.php)
-         *
-         * Description: Compares the heights or widths of the top-level children of a provided element 
-                and sets their min-height to the tallest height (or width to widest width). Sets in em units 
-                by default if pxToEm() method is available.
-         * Dependencies: jQuery library, pxToEm method (article: 
-                http://www.filamentgroup.com/lab/retaining_scalable_interfaces_with_pixel_to_em_conversion/)                                                     
-         * Usage Example: $(element).equalHeights();
-                Optional: to set min-height in px, pass a true argument: $(element).equalHeights(true);
-         * Version: 2.1, 18.12.2008
-         *
-         * Note: Changed pxToEm call to call $.pxToEm instead, jsLinted (Maxime Haineault <haineault@gmail.com>)
-        --------------------------------------------------------------------*/
-
-        equalHeights: function(px){
-            $(this).each(function(){
-                var currentTallest = 0;
-                $(this).children().each(function(i){
-                    if ($(this).height() > currentTallest) { currentTallest = $(this).height(); }
-                });
-                if (!px || !$.pxToEm) { currentTallest = $.pxToEm(currentTallest); } //use ems unless px is specified
-                // for ie6, set height since min-height isn't supported
-                if ($.browser.msie && $.browser.version == 6.0) { $(this).children().css({'height': currentTallest}); }
-                $(this).children().css({'min-height': currentTallest}); 
-            });
-            return this;
-        },
-
-        // Copyright (c) 2009 James Padolsey
-        // http://james.padolsey.com/javascript/jquery-delay-plugin/
-        delay: function(time, callback){
-            jQuery.fx.step.delay = function(){};
-            return this.animate({delay:1}, time, callback);
-        }        
-       });
-})(jQuery);
-
-/*
-  jQuery strings - 0.4
-  http://code.google.com/p/jquery-utils/
-  
-  (c) Maxime Haineault <haineault@gmail.com>
-  http://haineault.com   
-
-  MIT License (http://www.opensource.org/licenses/mit-license.php)
-
-  Implementation of Python3K advanced string formatting
-  http://www.python.org/dev/peps/pep-3101/
-
-  Documentation: http://code.google.com/p/jquery-utils/wiki/StringFormat
-  
-*/
-(function($){
-    var strings = {
-        strConversion: {
-            // tries to translate any objects type into string gracefully
-            __repr: function(i){
-                switch(this.__getType(i)) {
-                    case 'array':case 'date':case 'number':
-                        return i.toString();
-                    case 'object': // Thanks to Richard Paul Lewis for the fix
-                        var o = []; 
-                        var l = i.length;
-                        for(var x=0;x<l;x++) {
-                          o.push(x+': '+this.__repr(i[x]));
-                        } 
-                        return o.join(', ');                        
-                    case 'string': 
-                        return i;
-                    default: 
-                        return i;
-                }
-            },
-            // like typeof but less vague
-            __getType: function(i) {
-                if (!i || !i.constructor) { return typeof(i); }
-                var match = i.constructor.toString().match(/Array|Number|String|Object|Date/);
-                return match && match[0].toLowerCase() || typeof(i);
-            },
-            // Jonas Raoni Soares Silva (http://jsfromhell.com/string/pad)
-            __pad: function(str, l, s, t){
-                var p = s || ' ';
-                var o = str;
-                if (l - str.length > 0) {
-                    o = new Array(Math.ceil(l / p.length)).join(p).substr(0, t = !t ? l : t == 1 ? 0 : Math.ceil(l / 2)) + str + p.substr(0, l - t);
-                }
-                return o;
-            },
-            __getInput: function(arg, args) {
-                 var key = arg.getKey();
-                switch(this.__getType(args)){
-                    case 'object': // Thanks to Jonathan Works for the patch
-                        var keys = key.split('.');
-                        var obj = args;
-                        for(var subkey = 0; subkey < keys.length; subkey++){
-                            obj = obj[keys[subkey]];
-                        }
-                        if (typeof(obj) != 'undefined') {
-                            if (strings.strConversion.__getType(obj) == 'array') {
-                                return arg.getFormat().match(/\.\*/) && obj[1] || obj;
-                            }
-                            return obj;
-                        }
-                        else {
-                            // TODO: try by numerical index                    
-                        }
-                    break;
-                    case 'array': 
-                        key = parseInt(key, 10);
-                        if (arg.getFormat().match(/\.\*/) && typeof args[key+1] != 'undefined') { return args[key+1]; }
-                        else if (typeof args[key] != 'undefined') { return args[key]; }
-                        else { return key; }
-                    break;
-                }
-                return '{'+key+'}';
-            },
-            __formatToken: function(token, args) {
-                var arg   = new Argument(token, args);
-                return strings.strConversion[arg.getFormat().slice(-1)](this.__getInput(arg, args), arg);
-            },
-
-            // Signed integer decimal.
-            d: function(input, arg){
-                var o = parseInt(input, 10); // enforce base 10
-                var p = arg.getPaddingLength();
-                if (p) { return this.__pad(o.toString(), p, arg.getPaddingString(), 0); }
-                else   { return o; }
-            },
-            // Signed integer decimal.
-            i: function(input, args){ 
-                return this.d(input, args);
-            },
-            // Unsigned octal
-            o: function(input, arg){ 
-                var o = input.toString(8);
-                if (arg.isAlternate()) { o = this.__pad(o, o.length+1, '0', 0); }
-                return this.__pad(o, arg.getPaddingLength(), arg.getPaddingString(), 0);
-            },
-            // Unsigned decimal
-            u: function(input, args) {
-                return Math.abs(this.d(input, args));
-            },
-            // Unsigned hexadecimal (lowercase)
-            x: function(input, arg){
-                var o = parseInt(input, 10).toString(16);
-                o = this.__pad(o, arg.getPaddingLength(), arg.getPaddingString(),0);
-                return arg.isAlternate() ? '0x'+o : o;
-            },
-            // Unsigned hexadecimal (uppercase)
-            X: function(input, arg){
-                return this.x(input, arg).toUpperCase();
-            },
-            // Floating point exponential format (lowercase)
-            e: function(input, arg){
-                return parseFloat(input, 10).toExponential(arg.getPrecision());
-            },
-            // Floating point exponential format (uppercase)
-            E: function(input, arg){
-                return this.e(input, arg).toUpperCase();
-            },
-            // Floating point decimal format
-            f: function(input, arg){
-                return this.__pad(parseFloat(input, 10).toFixed(arg.getPrecision()), arg.getPaddingLength(), arg.getPaddingString(),0);
-            },
-            // Floating point decimal format (alias)
-            F: function(input, args){
-                return this.f(input, args);
-            },
-            // Floating point format. Uses exponential format if exponent is greater than -4 or less than precision, decimal format otherwise
-            g: function(input, arg){
-                var o = parseFloat(input, 10);
-                return (o.toString().length > 6) ? Math.round(o.toExponential(arg.getPrecision())): o;
-            },
-            // Floating point format. Uses exponential format if exponent is greater than -4 or less than precision, decimal format otherwise
-            G: function(input, args){
-                return this.g(input, args);
-            },
-            // Single character (accepts integer or single character string).  
-            c: function(input, args) {
-                var match = input.match(/\w|\d/);
-                return match && match[0] || '';
-            },
-            // String (converts any JavaScript object to anotated format)
-            r: function(input, args) {
-                return this.__repr(input);
-            },
-            // String (converts any JavaScript object using object.toString())
-            s: function(input, args) {
-                return input.toString && input.toString() || ''+input;
-            }
-        },
-
-        format: function(str, args) {
-            var end    = 0;
-            var start  = 0;
-            var match  = false;
-            var buffer = [];
-            var token  = '';
-            var tmp    = (str||'').split('');
-            for(start=0; start < tmp.length; start++) {
-                if (tmp[start] == '{' && tmp[start+1] !='{') {
-                    end   = str.indexOf('}', start);
-                    token = tmp.slice(start+1, end).join('');
-                    if (tmp[start-1] != '{' && tmp[end+1] != '}') {
-                        var tokenArgs = (typeof arguments[1] != 'object')? arguments2Array(arguments, 2): args || [];
-                        buffer.push(strings.strConversion.__formatToken(token, tokenArgs));
-                    }
-                    else {
-                        buffer.push(token);
-                    }
-                }
-                else if (start > end || buffer.length < 1) { buffer.push(tmp[start]); }
-            }
-            return (buffer.length > 1)? buffer.join(''): buffer[0];
-        },
-
-        calc: function(str, args) {
-            return eval(format(str, args));
-        },
-
-        repeat: function(s, n) { 
-            return new Array(n+1).join(s); 
-        },
-
-        UTF8encode: function(s) { 
-            return unescape(encodeURIComponent(s)); 
-        },
-
-        UTF8decode: function(s) { 
-            return decodeURIComponent(escape(s)); 
-        },
-
-        tpl: function() {
-            var out = '';
-            var render = true;
-            // Set
-            // $.tpl('ui.test', ['<span>', helloWorld ,'</span>']);
-            if (arguments.length == 2 && $.isArray(arguments[1])) {
-                this[arguments[0]] = arguments[1].join('');
-                return $(this[arguments[0]]);
-            }
-            // $.tpl('ui.test', '<span>hello world</span>');
-            if (arguments.length == 2 && $.isString(arguments[1])) {
-                this[arguments[0]] = arguments[1];
-                return $(this[arguments[0]]);
-            }
-            // Call
-            // $.tpl('ui.test');
-            if (arguments.length == 1) {
-                return $(this[arguments[0]]);
-            }
-            // $.tpl('ui.test', false);
-            if (arguments.length == 2 && arguments[1] == false) {
-                return this[arguments[0]];
-            }
-            // $.tpl('ui.test', {value:blah});
-            if (arguments.length == 2 && $.isObject(arguments[1])) {
-                return $($.format(this[arguments[0]], arguments[1]));
-            }
-            // $.tpl('ui.test', {value:blah}, false);
-            if (arguments.length == 3 && $.isObject(arguments[1])) {
-                return (arguments[2] == true) 
-                    ? $.format(this[arguments[0]], arguments[1])
-                    : $($.format(this[arguments[0]], arguments[1]));
-            }
-        }
-    };
-
-    var Argument = function(arg, args) {
-        this.__arg  = arg;
-        this.__args = args;
-        this.__max_precision = parseFloat('1.'+ (new Array(32)).join('1'), 10).toString().length-3;
-        this.__def_precision = 6;
-        this.getString = function(){
-            return this.__arg;
-        };
-        this.getKey = function(){
-            return this.__arg.split(':')[0];
-        };
-        this.getFormat = function(){
-            var match = this.getString().split(':');
-            return (match && match[1])? match[1]: 's';
-        };
-        this.getPrecision = function(){
-            var match = this.getFormat().match(/\.(\d+|\*)/g);
-            if (!match) { return this.__def_precision; }
-            else {
-                match = match[0].slice(1);
-                if (match != '*') { return parseInt(match, 10); }
-                else if(strings.strConversion.__getType(this.__args) == 'array') {
-                    return this.__args[1] && this.__args[0] || this.__def_precision;
-                }
-                else if(strings.strConversion.__getType(this.__args) == 'object') {
-                    return this.__args[this.getKey()] && this.__args[this.getKey()][0] || this.__def_precision;
-                }
-                else { return this.__def_precision; }
-            }
-        };
-        this.getPaddingLength = function(){
-            var match = false;
-            if (this.isAlternate()) {
-                match = this.getString().match(/0?#0?(\d+)/);
-                if (match && match[1]) { return parseInt(match[1], 10); }
-            }
-            match = this.getString().match(/(0|\.)(\d+|\*)/g);
-            return match && parseInt(match[0].slice(1), 10) || 0;
-        };
-        this.getPaddingString = function(){
-            var o = '';
-            if (this.isAlternate()) { o = ' '; }
-            // 0 take precedence on alternate format
-            if (this.getFormat().match(/#0|0#|^0|\.\d+/)) { o = '0'; }
-            return o;
-        };
-        this.getFlags = function(){
-            var match = this.getString().matc(/^(0|\#|\-|\+|\s)+/);
-            return match && match[0].split('') || [];
-        };
-        this.isAlternate = function() {
-            return !!this.getFormat().match(/^0?#/);
-        };
-    };
-
-    var arguments2Array = function(args, shift) {
-        var o = [];
-        for (l=args.length, x=(shift || 0)-1; x<l;x++) { o.push(args[x]); }
-        return o;
-    };
-    $.extend(strings);
-})(jQuery);
-
-/*
-  jQuery ui.timepickr - @VERSION
-  http://code.google.com/p/jquery-utils/
-
-  (c) Maxime Haineault <haineault@gmail.com> 
-  http://haineault.com
-
-  MIT License (http://www.opensource.org/licenses/mit-license.php
-
-  Note: if you want the original experimental plugin checkout the rev 224 
-
-  Dependencies
-  ------------
-  - jquery.utils.js
-  - jquery.strings.js
-  - jquery.ui.js
-  
-*/
-
-(function($) {
-
-$.tpl('timepickr.menu',   '<div class="ui-helper-reset ui-timepickr ui-widget" />');
-$.tpl('timepickr.row',    '<ol class="ui-timepickr-row ui-helper-clearfix" />');
-$.tpl('timepickr.button', '<li class="{className:s}"><span class="ui-state-default">{label:s}</span></li>');
-
-$.widget('ui.timepickr', {
-    plugins: {},
-    _create: function() {
-        this._dom = {
-            menu: $.tpl('timepickr.menu'),
-            row:  $.tpl('timepickr.menu')
-        };
-        this._trigger('initialize');
-        this._trigger('initialized');
-    },
-
-    _trigger: function(type, e, ui) {
-        var ui = ui || this;
-        $.ui.plugin.call(this, type, [e, ui]);
-        return $.Widget.prototype._trigger.call(this, type, e, ui);
-    },
-
-    _createButton: function(i, format, className) {
-        var o  = format && $.format(format, i) || i;
-        var cn = className && 'ui-timepickr-button '+ className || 'ui-timepickr-button';
-        return $.tpl('timepickr.button', {className: cn, label: o}).data('id', i)
-                .bind('mouseover', function(){
-                    $(this).siblings().find('span')
-                        .removeClass('ui-state-hover').end().end()
-                        .find('span').addClass('ui-state-hover');
-                });
-
-    },
-
-    _addRow: function(range, format, className, insertAfter) {
-        var ui  = this;
-        var btn = false;
-        var row = $.tpl('timepickr.row').bind('mouseover', function(){
-            $(this).next().show();
-        });
-        $.each(range, function(idx, val){
-            ui._createButton(val, format || false).appendTo(row);
-        });
-        if (className) {
-            $(row).addClass(className);
-        }
-        if (this.options.corners) {
-             row.find('span').addClass('ui-corner-'+ this.options.corners);
-        }
-        if (insertAfter) {
-            row.insertAfter(insertAfter);
-        }
-        else {
-            ui._dom.menu.append(row);
-        }
-        return row;
-    },
-
-    _setVal: function(val) {
-        val = val || this._getVal();
-        if (!(val.h==='' && val.m==='')) {        
-            this.element.data('timepickr.initialValue', val);
-            this.element.val(this._formatVal(val));        
-        }
-        if(this._dom.menu.is(':hidden')) {
-            this.element.trigger('change');
-        }
-    },
-
-    _getVal: function() {
-        var ols = this._dom.menu.find('ol');
-        function get(unit) {
-            var u = ols.filter('.'+unit).find('.ui-state-hover:first').text();
-            return u || ols.filter('.'+unit+'li:first span').text();
-        }
-        return {
-            h: get('hours'),
-            m: get('minutes'),
-            s: get('seconds'),
-            a: get('prefix'),
-            z: get('suffix'),
-            f: this.options['format'+ this.c],
-            c: this.c
-        };
-    },
-
-    _formatVal: function(ival) {
-        var val = ival || this._getVal();
-        val.c = this.options.convention;
-        val.f = val.c === 12 && this.options.format12 || this.options.format24;
-        return (new Time(val)).getTime();
-    },
-
-    blur: function() {
-        return this.element.blur();      
-    },
-
-    focus: function() {
-        return this.element.focus();      
-    },
-    show: function() {
-        this._trigger('show');
-        this.element.trigger(this.options.trigger);
-    },
-    hide: function() {
-        this._trigger('hide');
-        this._dom.menu.hide();
-    }
-
-});
-
-// These properties are shared accross every instances of timepickr 
-$.extend($.ui.timepickr.prototype, {
-    version:     '@VERSION',
-    //eventPrefix: '',
-    //getter:      '',
-    options:    {
-        convention:  24, // 24, 12
-        trigger:     'mouseover',
-        format12:    '{h:02.d}:{m:02.d} {z:s}',
-        format24:    '{h:02.d}:{m:02.d}',
-        hours:       true,
-        prefix:      ['am', 'pm'],
-        suffix:      ['am', 'pm'],
-        prefixVal:   false,
-        suffixVal:   true,
-        rangeHour12: $.range(1, 13),
-        rangeHour24: [$.range(0, 12), $.range(12, 24)],
-        rangeMin:    $.range(0, 60, 15),
-        rangeSec:    $.range(0, 60, 15),
-        corners:     'all',
-        // plugins
-        core:        true,
-        minutes:     true,
-        seconds:     false,
-        val:         false,
-        updateLive:  true,
-        resetOnBlur: true,
-        keyboardnav: true,
-        handle:      false,
-        handleEvent: 'click'
-    }
-});
-
-$.ui.plugin.add('timepickr', 'core', {
-    initialized: function(e, ui) {
-        var menu = ui._dom.menu;
-        var pos  = ui.element.position();
-
-        menu.insertAfter(ui.element).css('left', pos.left);
-
-        if (!$.boxModel) { // IE alignement fix
-            menu.css('margin-top', ui.element.height() + 8);
-        }
-        
-        ui.element
-            .bind(ui.options.trigger, function() {
-                ui._dom.menu.show();
-                ui._dom.menu.find('ol:first').show();
-                ui._trigger('focus');
-                if (ui.options.trigger != 'focus') {
-                    ui.element.focus();
-                }
-                ui._trigger('focus');
-            })
-            .bind('blur', function() {
-                ui.hide();
-                ui._trigger('blur');
-            });
-
-        menu.find('li').bind('mouseover.timepickr', function() {
-            ui._trigger('refresh');
-        });
-    },
-    refresh: function(e, ui) {
-        // Realign each menu layers
-        ui._dom.menu.find('ol').each(function(){
-            var p = $(this).prev('ol');
-            try { // .. to not fuckup IE
-                $(this).css('left', p.position().left + p.find('.ui-state-hover').position().left);
-            } catch(e) {};
-        });
-    }
-});
-
-$.ui.plugin.add('timepickr', 'hours', {
-    initialize: function(e, ui) {
-        if (ui.options.convention === 24) {
-            // prefix is required in 24h mode
-            ui._dom.prefix = ui._addRow(ui.options.prefix, false, 'prefix'); 
-
-            // split-range
-            if ($.isArray(ui.options.rangeHour24[0])) {
-                var range = [];
-                $.merge(range, ui.options.rangeHour24[0]);
-                $.merge(range, ui.options.rangeHour24[1]);
-                ui._dom.hours = ui._addRow(range, '{0:0.2d}', 'hours');
-                ui._dom.hours.find('li').slice(ui.options.rangeHour24[0].length, -1).hide();
-                var lis   = ui._dom.hours.find('li'); 
-
-                var show = [
-                    function() {
-                        lis.slice(ui.options.rangeHour24[0].length).hide().end()
-                           .slice(0, ui.options.rangeHour24[0].length).show()
-                           .filter(':visible:first').trigger('mouseover');
-
-                    },
-                    function() {
-                        lis.slice(0, ui.options.rangeHour24[0].length).hide().end()
-                           .slice(ui.options.rangeHour24[0].length).show()
-                           .filter(':visible:first').trigger('mouseover');
-                    }
-                ];
-
-                ui._dom.prefix.find('li').bind('mouseover.timepickr', function(){
-                    var index = ui._dom.menu.find('.prefix li').index(this);
-                    show[index].call();
-                });
-            }
-            else {
-                ui._dom.hours = ui._addRow(ui.options.rangeHour24, '{0:0.2d}', 'hours');
-                ui._dom.hours.find('li').slice(12, -1).hide();
-            }
-        }
-        else {
-            ui._dom.hours  = ui._addRow(ui.options.rangeHour12, '{0:0.2d}', 'hours');
-            // suffix is required in 12h mode
-            ui._dom.suffix = ui._addRow(ui.options.suffix, false, 'suffix'); 
-        }
-    }});
-
-$.ui.plugin.add('timepickr', 'minutes', {
-    initialize: function(e, ui) {
-        var p = ui._dom.hours && ui._dom.hours || false;
-        ui._dom.minutes = ui._addRow(ui.options.rangeMin, '{0:0.2d}', 'minutes', p);
-    }
-});
-
-$.ui.plugin.add('timepickr', 'seconds', {
-    initialize: function(e, ui) {
-        var p = ui._dom.minutes && ui._dom.minutes || false;
-        ui._dom.seconds = ui._addRow(ui.options.rangeSec, '{0:0.2d}', 'seconds', p);
-    }
-});
-
-$.ui.plugin.add('timepickr', 'val', {
-    initialized: function(e, ui) {
-        ui._setVal(ui.options.val);
-    }
-});
-
-$.ui.plugin.add('timepickr', 'updateLive', {
-    refresh: function(e, ui) {
-        ui._setVal();
-    }
-});
-
-$.ui.plugin.add('timepickr', 'resetOnBlur', {
-    initialized: function(e, ui) {
-        ui.element.data('timepickr.initialValue', ui._getVal());
-        ui._dom.menu.find('li > span').bind('mousedown.timepickr', function(){
-            ui.element.data('timepickr.initialValue', ui._getVal()); 
-        });
-    },
-    blur: function(e, ui) {
-        ui._setVal(ui.element.data('timepickr.initialValue'));
-    }
-});
-
-$.ui.plugin.add('timepickr', 'handle', {
-    initialized: function(e, ui) {
-        $(ui.options.handle).bind(ui.options.handleEvent + '.timepickr', function(){
-            ui.show();
-        });
-    }
-});
-
-$.ui.plugin.add('timepickr', 'keyboardnav', {
-    initialized: function(e, ui) {
-        ui.element
-            .bind('keydown', function(e) {
-                if ($.keyIs('enter', e)) {
-                    ui._setVal();
-                    ui.blur();
-                }
-                else if ($.keyIs('escape', e)) {
-                    ui.blur();
-                }
-            });
-    }
-});
-
-var Time = function() { // arguments: h, m, s, c, z, f || time string
-    if (!(this instanceof arguments.callee)) {
-        throw Error("Constructor called as a function");
-    }
-    // arguments as literal object
-    if (arguments.length == 1 && $.isObject(arguments[0])) {
-        this.h = arguments[0].h || 0;
-        this.m = arguments[0].m || 0;
-        this.s = arguments[0].s || 0;
-        this.c = arguments[0].c && ($.inArray(arguments[0].c, [12, 24]) >= 0) && arguments[0].c || 24;
-        this.f = arguments[0].f || ((this.c == 12) && '{h:02.d}:{m:02.d} {z:02.d}' || '{h:02.d}:{m:02.d}');
-        this.z = arguments[0].z || 'am';
-    }
-    // arguments as string
-    else if (arguments.length < 4 && $.isString(arguments[1])) {
-        this.c = arguments[2] && ($.inArray(arguments[0], [12, 24]) >= 0) && arguments[0] || 24;
-        this.f = arguments[3] || ((this.c == 12) && '{h:02.d}:{m:02.d} {z:02.d}' || '{h:02.d}:{m:02.d}');
-        this.z = arguments[4] || 'am';
-        
-        this.h = arguments[1] || 0; // parse
-        this.m = arguments[1] || 0; // parse
-        this.s = arguments[1] || 0; // parse
-    }
-    // no arguments (now)
-    else if (arguments.length === 0) {
-        // now
-    }
-    // standards arguments
-    else {
-        this.h = arguments[0] || 0;
-        this.m = arguments[1] || 0;
-        this.s = arguments[2] || 0;
-        this.c = arguments[3] && ($.inArray(arguments[3], [12, 24]) >= 0) && arguments[3] || 24;
-        this.f = this.f || ((this.c == 12) && '{h:02.d}:{m:02.d} {z:02.d}' || '{h:02.d}:{m:02.d}');
-        this.z = 'am';
-    }
-    return this;
-};
-
-Time.prototype.get        = function(p, f, u)    { return u && this.h || $.format(f, this.h); };
-Time.prototype.getHours   = function(unformated) { return this.get('h', '{0:02.d}', unformated); };
-Time.prototype.getMinutes = function(unformated) { return this.get('m', '{0:02.d}', unformated); };
-Time.prototype.getSeconds = function(unformated) { return this.get('s', '{0:02.d}', unformated); };
-Time.prototype.setFormat  = function(format)     { return this.f = format; };
-Time.prototype.getObject  = function()           { return { h: this.h, m: this.m, s: this.s, c: this.c, f: this.f, z: this.z }; };
-Time.prototype.getTime    = function()           { return $.format(this.f, {h: this.h, m: this.m, suffix: this.z}); }; // Thanks to Jackson for the fix.
-Time.prototype.parse      = function(str) { 
-    // 12h formats
-    if (this.c === 12) {
-        // Supported formats: (can't find any *official* standards for 12h..)
-        //  - [hh]:[mm]:[ss] [zz] | [hh]:[mm] [zz] | [hh] [zz] 
-        //  - [hh]:[mm]:[ss] [z.z.] | [hh]:[mm] [z.z.] | [hh] [z.z.]
-        this.tokens = str.split(/\s|:/);    
-        this.h = this.tokens[0] || 0;
-        this.m = this.tokens[1] || 0;
-        this.s = this.tokens[2] || 0;
-        this.z = this.tokens[3] || '';
-        return this.getObject();
-    }
-    // 24h formats
-    else { 
-        // Supported formats:
-        //  - ISO 8601: [hh][mm][ss] | [hh][mm] | [hh]  
-        //  - ISO 8601 extended: [hh]:[mm]:[ss] | [hh]:[mm] | [hh]
-        this.tokens = /:/.test(str) && str.split(/:/) || str.match(/[0-9]{2}/g);
-        this.h = this.tokens[0] || 0;
-        this.m = this.tokens[1] || 0;
-        this.s = this.tokens[2] || 0;
-        this.z = this.tokens[3] || '';
-        return this.getObject();
-    }
-};
-
-})(jQuery);
index 5bfce41..fe5c0a3 100644 (file)
@@ -222,35 +222,47 @@ function doOnLoad( js ) {
 }
 
 jQuery(function() {
-    jQuery(".ui-datepicker:not(.withtime)").datepicker( {
-        dateFormat: 'yy-mm-dd',
-        constrainInput: false
-    } );
-
-    jQuery(".ui-datepicker.withtime").datepicker( {
+    var opts = {
         dateFormat: 'yy-mm-dd',
         constrainInput: false,
-        onSelect: function( dateText, inst ) {
-            // trigger timepicker to get time
-            var button = document.createElement('input');
-            button.setAttribute('type',  'button');
-            jQuery(button).width('5em');
-            jQuery(button).insertAfter(this);
-            jQuery(button).timepickr({val: '00:00'});
-            var date_input = this;
-
-            jQuery(button).blur( function() {
-                var time = jQuery(button).val();
-                if ( ! time.match(/\d\d:\d\d/) ) {
-                    time = '00:00';
-                }
-                jQuery(date_input).val(  dateText + ' ' + time + ':00' );
-                jQuery(button).remove();
-            } );
-
-            jQuery(button).focus();
-        }
-    } );
+        showButtonPanel: true,
+        changeMonth: true,
+        changeYear: true,
+        showOtherMonths: true,
+        selectOtherMonths: true
+    };
+    jQuery(".ui-datepicker:not(.withtime)").datepicker(opts);
+    jQuery(".ui-datepicker.withtime").datetimepicker( jQuery.extend({}, opts, {
+        stepHour: 1,
+        // We fake this by snapping below for the minute slider
+        //stepMinute: 5,
+        hourGrid: 6,
+        minuteGrid: 15,
+        showSecond: false,
+        timeFormat: 'hh:mm:ss'
+    }) ).each(function(index, el) {
+        var tp = jQuery.datepicker._get( jQuery.datepicker._getInst(el), 'timepicker');
+        if (!tp) return;
+
+        // Hook after _injectTimePicker so we can modify the minute_slider
+        // right after it's first created
+        tp._base_injectTimePicker = tp._injectTimePicker;
+        tp._injectTimePicker = function() {
+            this._base_injectTimePicker.apply(this, arguments);
+
+            // Now that we have minute_slider, modify it to be stepped for mouse movements
+            var slider = jQuery.data(this.minute_slider[0], "slider");
+            slider._base_normValueFromMouse = slider._normValueFromMouse;
+            slider._normValueFromMouse = function() {
+                var value           = this._base_normValueFromMouse.apply(this, arguments);
+                var old_step        = this.options.step;
+                this.options.step   = 5;
+                var aligned         = this._trimAlignValue( value );
+                this.options.step   = old_step;
+                return aligned;
+            };
+        };
+    });
 });
 
 function textToHTML(value) {
index 9f7e04a..b5d3edd 100644 (file)
@@ -53,6 +53,7 @@
 % foreach my $section( RT->Config->Sections ) {
 <&|/Widgets/TitleBox, title => loc( $section ) &>
 % foreach my $option( RT->Config->Options( Section => $section ) ) {
+% next if $option eq 'EmailFrequency' && !RT->Config->Get('RecordOutgoingEmail');
 % my $meta = RT->Config->Meta( $option );
 <& $meta->{'Widget'},
     Default      => 1,
index 9a2212b..016a50c 100755 (executable)
@@ -167,6 +167,17 @@ else {
             elsif (lc $k eq 'text') {
                 $text = delete $data{$k};
             }
+            elsif ( lc $k ne 'id' ) {
+                $e = 1;
+                push @$o, $k;
+                push(@comments, "# $k: Unknown field");
+            }
+        }
+
+        if ( $e ) {
+            unshift @comments, "# Could not create ticket.";
+            $k = \%data;
+            goto DONE;
         }
 
         # people fields allow multiple values
@@ -292,8 +303,10 @@ else {
         elsif (exists $simple{$key}) {
             $key = $simple{$key};
             $set = "Set$key";
+            my $current = $ticket->$key;
+            $current = '' unless defined $current;
 
-            next if (($val eq ($ticket->$key||''))|| ($ticket->$key =~ /^\d+$/ && $val =~ /^\d+$/ && $val == $ticket->$key));
+            next if ($val eq $current) or ($current =~ /^\d+$/ && $val =~ /^\d+$/ && $val == $current);
             ($n, $s) = $ticket->$set("$val");
         }
         elsif (exists $dates{$key}) {
@@ -331,13 +344,6 @@ else {
                 }
             }
             foreach $p (keys %new) {
-                # XXX: This is a stupid test.
-                unless ($p =~ /^[\w.+-]+\@([\w.-]+\.)*\w+.?$/) {
-                    $s = 0;
-                    $n = "$p is not a valid email address.";
-                    push @msgs, [ $s, $n ];
-                    next;
-                }
                 unless ($ticket->IsWatcher(Type => $type, Email => $p)) {
                     ($s, $n) = $ticket->AddWatcher(Type => $type,
                                                    Email => $p);
index 070ce7c..571c3d3 100644 (file)
@@ -98,14 +98,14 @@ my %query;
 
     for(@session_fields) {
         $query{$_} = $current->{$_} unless defined $query{$_};
-        $query{$_} = $m->request_args->{$_} unless defined $query{$_};
+        $query{$_} = $DECODED_ARGS->{$_} unless defined $query{$_};
     }
 
-    if ($m->request_args->{'SavedSearchLoadSubmit'}) {
-        $query{'SavedChartSearchId'} = $m->request_args->{'SavedSearchLoad'};
+    if ($DECODED_ARGS->{'SavedSearchLoadSubmit'}) {
+        $query{'SavedChartSearchId'} = $DECODED_ARGS->{'SavedSearchLoad'};
     }
 
-    if ($m->request_args->{'SavedSearchSave'}) {
+    if ($DECODED_ARGS->{'SavedSearchSave'}) {
         $query{'SavedChartSearchId'} = $saved_search->{'SearchId'};
     }
 
index d07e49c..bc29111 100644 (file)
@@ -62,7 +62,7 @@
 
 <%INIT>
 my @types;
-if ($Scope =~ 'queue') {
+if ($Scope =~ /queue/) {
    @types = qw(Cc AdminCc);
 }
 elsif ($Suffix eq 'Group') {
index 171b38d..4fee865 100755 (executable)
@@ -151,6 +151,7 @@ if ($ARGS{'TicketsRefreshInterval'}) {
 my $refresh = $session{'tickets_refresh_interval'}
     || RT->Config->Get('SearchResultsRefreshInterval', $session{'CurrentUser'} );
 
+# Check $m->request_args, not $DECODED_ARGS, to avoid creating a new CSRF token on each refresh
 if (RT->Config->Get('RestrictReferrer') and $refresh and not $m->request_args->{CSRF_Token}) {
     my $token = RT::Interface::Web::StoreRequestToken( $session{'CurrentSearchHash'} );
     $m->notes->{RefreshURL} = RT->Config->Get('WebURL')
index 52a05da..8b94e22 100644 (file)
@@ -54,6 +54,7 @@ $Format => undef
 <%INIT>
 
 use Spreadsheet::WriteExcel;
+use OLE::Storage_Lite;
 use List::Util qw( max );
 use Date::Format qw( time2str );
 
index 75efeff..eb291e4 100755 (executable)
@@ -48,7 +48,7 @@
 <%perl>
     my ($ticket, $trans,$attach, $filename);
     my $arg = $m->dhandler_arg;                # get rest of path
-    if ($arg =~ '^(\d+)/(\d+)') {
+    if ($arg =~ m{^(\d+)/(\d+)}) {
         $trans = $1;
         $attach = $2;
     }
      my $enc = $AttachmentObj->OriginalEncoding || 'utf-8';
      my $iana = Encode::find_encoding( $enc );
      $iana = $iana? $iana->mime_name : $enc;
-        $content_type .= ";charset=$iana";
+
+     require MIME::Types;
+     my $mimetype = MIME::Types->new->type($content_type);
+     unless ( $mimetype && $mimetype->isBinary ) {
+           $content_type .= ";charset=$iana";
+     }
 
      $r->subprocess_env('no-gzip' => 1); # disable mod_deflate
      $r->content_type( $content_type );
index 3c2c82a..13fb2f0 100644 (file)
@@ -58,7 +58,7 @@ my @Customers = ();
 if ( $CustomerString ) {
     @Customers = &RT::URI::freeside::smart_search(
         'search'            => $CustomerString,
-        'no_fuzzy_on_exact' => 1, #pref?
+        'no_fuzzy_on_exact' => ! $FS::CurrentUser::CurrentUser->option('enable_fuzzy_on_exact'),
     );
 }
 
index c17c6e7..1ffbda2 100755 (executable)
@@ -48,8 +48,9 @@
 <ul>
 % while (my $link = $members->Next) {
 <li><& /Elements/ShowLink, URI => $link->BaseURI &><br />
+% next if $link->BaseObj and $checked->{$link->BaseObj->id};
 % if ($depth < 8) {
-<& /Ticket/Elements/ShowMembers, Ticket => $link->BaseObj, depth => ($depth+1) &> 
+<& /Ticket/Elements/ShowMembers, Ticket => $link->BaseObj, depth => ($depth+1), checked => $checked &> 
 % }
 </li>
 % }
@@ -61,9 +62,13 @@ return unless $Ticket;
 my $members = $Ticket->Members;
 return unless $members->Count;
 
+return if $checked->{$Ticket->id};
+
+$checked->{$Ticket->id} = 1;
 </%INIT>
 
 <%ARGS>
 $Ticket => undef
 $depth => 1
+$checked => {}
 </%ARGS>
index 877201f..95a2341 100644 (file)
@@ -144,6 +144,8 @@ my $render_attachment = sub {
     my $message = shift;
     my $name = defined $message->Filename && length $message->Filename ?  $message->Filename : '';
 
+    my $content_type = lc $message->ContentType;
+
     # if it has a content-disposition: attachment, don't show inline
     my $disposition = $message->GetHeader('Content-Disposition');
 
@@ -154,7 +156,7 @@ my $render_attachment = sub {
     }
 
     # If it's text
-    if ( $message->ContentType =~ m{^(text|message)}i ) {
+    if ( $content_type =~ m{^(text|message)/} ) {
         my $max_size = RT->Config->Get( 'MaxInlineBody', $session{'CurrentUser'} );
         if ( $disposition ne 'inline' ) {
             $m->out('<p>'. loc( 'Message body is not shown because sender requested not to inline it.' ) .'</p>');
@@ -175,16 +177,16 @@ my $render_attachment = sub {
             !$ParentObj
 
             # or its parent isn't a multipart alternative
-            || ( $ParentObj->ContentType !~ m{^multipart/alternative$}i )
+            || ( $ParentObj->ContentType !~ m{^multipart/(?:alternative|related)$}i )
 
             # or it's of our prefered alterative type
             || (
                 (
                     RT->Config->Get('PreferRichText')
-                    && ( $message->ContentType =~ m{^text/(?:html|enriched)$} )
+                    && ( $content_type =~ m{^text/(?:html|enriched)$} )
                 )
                 || ( !RT->Config->Get('PreferRichText')
-                    && ( $message->ContentType !~ m{^text/(?:html|enriched)$} )
+                    && ( $content_type !~ m{^text/(?:html|enriched)$} )
                 )
             )
         ) {
@@ -198,7 +200,6 @@ my $render_attachment = sub {
                 $content = $message->Content;
             }
 
-            my $content_type = lc $message->ContentType;
             $RT::Logger->debug(
                 "Rendering attachment #". $message->id
                 ." of '$content_type' type"
@@ -231,9 +232,8 @@ my $render_attachment = sub {
                 $m->out( $content );
             }
 
-            # if it's a text/plain show the body
-            elsif ( $message->ContentType =~ m{^(text|message)}i ) {
-
+            # It's a text type we don't have special handling for
+            else {
                 unless ( length $name ) {
                     eval { require Text::Quoted;  $content = Text::Quoted::extract($content); };
                     if ($@) { $RT::Logger->warning( "Text::Quoted failed: $@" ) }
@@ -250,7 +250,7 @@ my $render_attachment = sub {
     }
 
     # if it's an image, show it as an image
-    elsif ( RT->Config->Get('ShowTransactionImages') and  $message->ContentType =~ /^image\//i ) {
+    elsif ( RT->Config->Get('ShowTransactionImages') and  $content_type =~ m{^image/} ) {
         if ( $disposition ne 'inline' ) {
             $m->out('<p>'. loc( 'Message body is not shown because sender requested not to inline it.' ) .'</p>');
             return;
index 8c19977..a349829 100644 (file)
@@ -33,6 +33,7 @@ div.buttons {
     background-color: #ccc;
     -moz-border-radius: 0.25em;
     -webkit-border-radius: 0.25em;
+    border-radius: 0.25em;
     -webkit-box-shadow: #333 0px 0px 5px;
     -moz-box-shadow: #333 0px 0px 5px;
     box-shadow: #333 0px 0px 5px;
@@ -85,6 +86,7 @@ ul.menu li#active a
 div.titlebox, #bpscredits, .ticket_menu{
     -moz-border-radius: 1em;
     -webkit-border-radius: 1em;
+    border-radius: 1em;
     margin: 0.5em;
     background-color: #fff;
     padding-top: 1em;
@@ -336,6 +338,7 @@ input[type=submit], input[type=button], button, #paging a {
     padding-right: 0.6em;
     -moz-border-radius: 0.5em;
     -webkit-border-radius: 0.5em;
+    border-radius: 0.5em;
     background-color: #006699;
     color: #fff;
 }
@@ -353,6 +356,7 @@ form {
     border-bottom: 1px solid black;
     -moz-border-radius-bottomleft: 1em;
     -webkit-border-bottom-left-radius: 1em;
+    border-bottom-left-radius: 1em;
     padding: 0.5em;
     background-color: #fff;
 }
index 794385d..1891079 100644 (file)
@@ -3,7 +3,7 @@ $title => ''
 $show_home_button => 1
 </%args>
 <%init>
-if ($m->request_args->{'NotMobile'}) {
+if ($DECODED_ARGS->{'NotMobile'}) {
     $session{'NotMobile'} = 1;
     RT::Interface::Web::Redirect(RT->Config->Get('WebURL'));
     $m->abort();
index a986c3c..62b77df 100644 (file)
@@ -1,7 +1,8 @@
 use strict;
 use warnings;
 use RT;
-use RT::Test nodb => 1, tests => 9;
+use RT::Test nodb => 1, tests => 11;
+use Test::Warn;
 
 ok(
     RT::Config->AddOption(
@@ -31,3 +32,12 @@ is( $meta->{Widget}, '/Widgets/Form/Boolean', 'widget is updated to boolean' );
 ok( RT::Config->DeleteOption( Name => 'foo' ), 'removed option foo' );
 is( RT::Config->Meta('foo'), undef, 'foo is indeed deleted' );
 
+# Test EmailInputEncodings PostLoadCheck code
+RT::Config->Set('EmailInputEncodings', qw(utf-8 iso-8859-1 us-ascii foo));
+my @encodings = qw(utf-8-strict iso-8859-1 ascii);
+
+warning_is {RT::Config->PostLoadCheck} "Unknown encoding 'foo' in \@EmailInputEncodings option",
+  'Correct warning for encoding foo';
+
+my @canonical_encodings = RT::Config->Get('EmailInputEncodings');
+is_deeply(\@encodings, \@canonical_encodings, 'Got correct encoding list');
diff --git a/rt/t/api/template-insert.t b/rt/t/api/template-insert.t
deleted file mode 100644 (file)
index 1bf5fc3..0000000
+++ /dev/null
@@ -1,26 +0,0 @@
-#!/usr/bin/perl
-
-use warnings;
-use strict;
-
-
-use RT;
-use RT::Test tests => 7;
-
-
-
-# This tiny little test script triggers an interaction bug between DBD::Oracle 1.16, SB 1.15 and RT 3.4
-
-use_ok('RT::Template');
-my $template = RT::Template->new(RT->SystemUser);
-
-isa_ok($template, 'RT::Template');
-my ($val,$msg) = $template->Create(Queue => 1,
-                  Name => 'InsertTest',
-                  Content => 'This is template content');
-ok($val,$msg);
-is($template->Name, 'InsertTest');
-is($template->Content, 'This is template content', "We created the object right");
-($val, $msg) = $template->SetContent( 'This is new template content');
-ok($val,$msg);
-is($template->Content, 'This is new template content', "We managed to _Set_ the content");
diff --git a/rt/t/api/template-simple.t b/rt/t/api/template-simple.t
deleted file mode 100644 (file)
index bbdebb3..0000000
+++ /dev/null
@@ -1,275 +0,0 @@
-use strict;
-use warnings;
-use RT;
-use RT::Test tests => 231;
-use Test::Warn;
-
-my $queue = RT::Queue->new(RT->SystemUser);
-$queue->Load("General");
-
-my $ticket_cf = RT::CustomField->new(RT->SystemUser);
-$ticket_cf->Create(
-    Name        => 'Department',
-    Queue       => '0',
-    Type        => 'FreeformSingle',
-);
-
-my $txn_cf = RT::CustomField->new(RT->SystemUser);
-$txn_cf->Create(
-    Name        => 'Category',
-    LookupType  => RT::Transaction->CustomFieldLookupType,
-    Type        => 'FreeformSingle',
-);
-$txn_cf->AddToObject($queue);
-
-my $ticket = RT::Ticket->new(RT->SystemUser);
-my ($id, $msg) = $ticket->Create(
-    Subject   => "template testing",
-    Queue     => "General",
-    Owner     => 'root@localhost',
-    Requestor => ["dom\@example.com"],
-    "CustomField-" . $txn_cf->id => "Special",
-);
-ok($id, "Created ticket: $msg");
-my $txn = $ticket->Transactions->First;
-
-$ticket->AddCustomFieldValue(
-    Field => 'Department',
-    Value => 'Coolio',
-);
-
-TemplateTest(
-    Content      => "\ntest",
-    PerlOutput   => "test",
-    SimpleOutput => "test",
-);
-
-TemplateTest(
-    Content      => "\ntest { 5 * 5 }",
-    PerlOutput   => "test 25",
-    SimpleOutput => "test { 5 * 5 }",
-);
-
-TemplateTest(
-    Content      => "\ntest { \$Requestor }",
-    PerlOutput   => "test dom\@example.com",
-    SimpleOutput => "test dom\@example.com",
-);
-
-TemplateTest(
-    Content      => "\ntest { \$TicketSubject }",
-    PerlOutput   => "test ",
-    SimpleOutput => "test template testing",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketQueueId }",
-    Output  => "test 1",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketQueueName }",
-    Output  => "test General",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketOwnerId }",
-    Output  => "test 12",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketOwnerName }",
-    Output  => "test root",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketOwnerEmailAddress }",
-    Output  => "test root\@localhost",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketStatus }",
-    Output  => "test new",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest #{ \$TicketId }",
-    Output  => "test #" . $ticket->id,
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketCFDepartment }",
-    Output  => "test Coolio",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest #{ \$TransactionId }",
-    Output  => "test #" . $txn->id,
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TransactionType }",
-    Output  => "test Create",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TransactionCFCategory }",
-    Output  => "test Special",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$TicketDelete }",
-    Output  => "test { \$TicketDelete }",
-);
-
-SimpleTemplateTest(
-    Content => "\ntest { \$Nonexistent }",
-    Output  => "test { \$Nonexistent }",
-);
-
-warning_like {
-    TemplateTest(
-        Content      => "\ntest { \$Ticket->Nonexistent }",
-        PerlOutput   => undef,
-        SimpleOutput => "test { \$Ticket->Nonexistent }",
-    );
-} qr/RT::Ticket::Nonexistent Unimplemented/;
-
-warning_like {
-    TemplateTest(
-        Content      => "\ntest { \$Nonexistent->Nonexistent }",
-        PerlOutput   => undef,
-        SimpleOutput => "test { \$Nonexistent->Nonexistent }",
-    );
-} qr/Can't call method "Nonexistent" on an undefined value/;
-
-TemplateTest(
-    Content      => "\ntest { \$Ticket->OwnerObj->Name }",
-    PerlOutput   => "test root",
-    SimpleOutput => "test { \$Ticket->OwnerObj->Name }",
-);
-
-warning_like {
-    TemplateTest(
-        Content      => "\ntest { *!( }",
-        SyntaxError  => 1,
-        PerlOutput   => undef,
-        SimpleOutput => "test { *!( }",
-    );
-} qr/Template parsing error: syntax error/;
-
-TemplateTest(
-    Content      => "\ntest { \$rtname ",
-    SyntaxError  => 1,
-    PerlOutput   => undef,
-    SimpleOutput => undef,
-);
-
-is($ticket->Status, 'new', "test setup");
-SimpleTemplateTest(
-    Content => "\ntest { \$Ticket->SetStatus('resolved') }",
-    Output  => "test { \$Ticket->SetStatus('resolved') }",
-);
-is($ticket->Status, 'new', "simple templates can't call ->SetStatus");
-
-# Make sure changing the template's type works
-my $template = RT::Template->new(RT->SystemUser);
-$template->Create(
-    Name    => "type chameleon",
-    Type    => "Perl",
-    Content => "\ntest { 10 * 7 }",
-);
-ok($id = $template->id, "Created template");
-$template->Parse;
-is($template->MIMEObj->stringify_body, "test 70", "Perl output");
-
-$template = RT::Template->new(RT->SystemUser);
-$template->Load($id);
-is($template->Name, "type chameleon");
-
-$template->SetType('Simple');
-$template->Parse;
-is($template->MIMEObj->stringify_body, "test { 10 * 7 }", "Simple output");
-
-$template = RT::Template->new(RT->SystemUser);
-$template->Load($id);
-is($template->Name, "type chameleon");
-
-$template->SetType('Perl');
-$template->Parse;
-is($template->MIMEObj->stringify_body, "test 70", "Perl output");
-
-undef $ticket;
-
-my $counter = 0;
-sub IndividualTemplateTest {
-    local $Test::Builder::Level = $Test::Builder::Level + 1;
-
-    my %args = (
-        Name => "Test-" . ++$counter,
-        Type => "Perl",
-        @_,
-    );
-
-    my $t = RT::Template->new(RT->SystemUser);
-    $t->Create(
-        Name    => $args{Name},
-        Type    => $args{Type},
-        Content => $args{Content},
-    );
-
-    ok($t->id, "Created $args{Type} template");
-    is($t->Name, $args{Name}, "$args{Type} template name");
-    is($t->Content, $args{Content}, "$args{Type} content");
-    is($t->Type, $args{Type}, "template type");
-
-    # this should never blow up!
-    my ($ok, $msg) = $t->CompileCheck;
-
-    # we don't need to syntax check simple templates since if you mess them up
-    # it's safe to just use the input directly as the template's output
-    if ($args{SyntaxError} && $args{Type} eq 'Perl') {
-        ok(!$ok, "got a syntax error");
-    }
-    else {
-        ok($ok, $msg);
-    }
-
-    ($ok, $msg) = $t->Parse(
-        TicketObj      => $ticket,
-        TransactionObj => $txn,
-    );
-    if (defined $args{Output}) {
-        ok($ok, $msg);
-        is($t->MIMEObj->stringify_body, $args{Output}, "$args{Type} template's output");
-    }
-    else {
-        ok(!$ok, "expected a failure");
-    }
-}
-
-sub TemplateTest {
-    local $Test::Builder::Level = $Test::Builder::Level + 1;
-    my %args = @_;
-
-    for my $type ('Perl', 'Simple') {
-        next if $args{"Skip$type"};
-
-        IndividualTemplateTest(
-            %args,
-            Type   => $type,
-            Output => $args{$type . 'Output'},
-        );
-    }
-}
-
-sub SimpleTemplateTest {
-    local $Test::Builder::Level = $Test::Builder::Level + 1;
-    my %args = @_;
-
-    IndividualTemplateTest(
-        %args,
-        Type => 'Simple',
-    );
-}
-
index 2fadede..331d9f9 100644 (file)
@@ -1,25 +1,34 @@
 
-use strict;
 use warnings;
-use RT;
-use RT::Test tests => 2;
-
-
-{
-
-ok(require RT::Template);
+use strict;
 
+use RT;
+use RT::Test tests => 10;
 
-}
+my $queue = RT::Test->load_or_create_queue( Name => 'Templates' );
+ok $queue && $queue->id, "loaded or created a queue";
 
 {
-
-my $t = RT::Template->new(RT->SystemUser);
-$t->Create(Name => "Foo", Queue => 1);
-my $t2 = RT::Template->new(RT->Nobody);
-$t2->Load($t->Id);
-ok($t2->QueueObj->id, "Got the template's queue objet");
-
-
+    my $template = RT::Template->new( RT->SystemUser );
+    isa_ok($template, 'RT::Template');
+    my ($val,$msg) = $template->Create(
+        Queue => $queue->id,
+        Name => 'InsertTest',
+        Content => 'This is template content'
+    );
+    ok $val, "created a template" or diag "error: $msg";
+    ok my $id = $template->id, "id is defined";
+    is $template->Name, 'InsertTest';
+    is $template->Content, 'This is template content', "We created the object right";
+
+    ($val, $msg) = $template->SetContent( 'This is new template content');
+    ok $val, "changed content" or diag "error: $msg";
+
+    is $template->Content, 'This is new template content', "We managed to _Set_ the content";
+
+    ($val, $msg) = $template->Delete;
+    ok $val, "deleted template";
+
+    $template->Load($id);
+    ok !$template->id, "can not load template after deletion";
 }
-
index eb3a4f7..bcba311 100644 (file)
@@ -3,7 +3,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 23;
+use RT::Test tests => 44;
 
 use RT::CustomField;
 use RT::Queue;
@@ -67,7 +67,12 @@ my %cvals = ('article1q' => 'Some question about swallows',
                'article3q' => 'Why should I eat my supper?',
                'article3a' => 'There are starving children in Africa',
                'article4q' => 'What did Brian originally write?',
-               'article4a' => 'Romanes eunt domus');
+               'article4a' => 'This is an answer that is longer than 255 '
+            . 'characters so these tests will be sure to use the LargeContent '
+            . 'SQL as well as the normal SQL that would be generated if this '
+            . 'was an answer that was shorter than 255 characters. This second '
+            . 'sentence has a few extra characters to get this string to go '
+            . 'over the 255 character boundary. Lorem ipsum.');
 
 # Create an article or two with our custom field values.
 
@@ -108,6 +113,52 @@ isa_ok($m, 'Test::WWW::Mechanize');
 ok($m->login, 'logged in');
 $m->follow_link_ok( { text => 'Articles', url_regex => qr!^/Articles/! },
     'UI -> Articles' );
-$m->follow_link_ok( {text => 'Search'}, 'Articles -> Search');
-$m->follow_link_ok( {text => 'in class '.$class->Name}, 'Articles in class '.$class->Name);
-$m->content_contains($article1->Name);
+
+# In all of the search results below, the results page should
+# have the summary text of the article it occurs in.
+
+# Case sensitive search on small field.
+DoArticleSearch($m, $class->Name, 'Africa');
+$m->text_contains('Search results'); # Did we do a search?
+$m->text_contains('blah blah 1');
+
+# Case insensitive search on small field.
+DoArticleSearch($m, $class->Name, 'africa');
+$m->text_contains('Search results'); # Did we do a search?
+$m->text_contains('blah blah 1');
+
+# Case sensitive search on large field.
+DoArticleSearch($m, $class->Name, 'ipsum');
+$m->text_contains('Search results'); # Did we do a search?
+$m->text_contains('hoi polloi 4');
+
+# Case insensitive search on large field.
+DoArticleSearch($m, $class->Name, 'lorem');
+$m->text_contains('Search results'); # Did we do a search?
+TODO:{
+    local $TODO = 'Case insensitive search on LONGBLOB not available in MySQL'
+      if RT->Config->Get('DatabaseType') eq 'mysql';
+    $m->text_contains('hoi polloi 4');
+}
+
+# When you send $m to this sub, it must be on a page with
+# a Search link.
+sub DoArticleSearch{
+  my $m = shift;
+  my $class_name = shift;
+  my $search_text = shift;
+
+  $m->follow_link_ok( {text => 'Search'}, 'Articles -> Search');
+  $m->follow_link_ok( {text => 'in class '. $class_name}, 'Articles in class '. $class_name);
+  $m->text_contains('First article');
+
+  $m->submit_form_ok( {
+            form_number => 3,
+            fields      => {
+                'Article~' => $search_text
+            },
+        }, "Search for $search_text"
+    );
+  return;
+}
+
index 82d0f1b..5c1fdaf 100644 (file)
@@ -3,7 +3,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 7;
+use RT::Test tests => 15;
 
 use_ok("RT::URI::a");
 my $uri = RT::URI::a->new($RT::SystemUser);
@@ -26,3 +26,39 @@ is(ref($uri->Object), "RT::Article", "Object loaded is an article");
 is($uri->Object->Id, $article->Id, "Object loaded has correct ID");
 is($article->URI, 'fsck.com-article://example.com/article/'.$article->Id, 
    "URI object has correct URI string");
+
+{
+    my $aid = $article->id;
+    my $ticket = RT::Ticket->new( RT->SystemUser );
+    my ($id, $msg) = $ticket->Create(
+        Queue       => 1,
+        Subject     => 'test ticket',
+    );
+    ok $id, "Created a test ticket";
+
+    # Try searching
+    my $tickets = RT::Tickets->new( RT->SystemUser );
+    $tickets->FromSQL(" RefersTo = 'a:$aid' ");
+    is $tickets->Count, 0, "No results yet";
+
+    # try with the full uri
+    $tickets->FromSQL(" RefersTo = '@{[ $article->URI ]}' ");
+    is $tickets->Count, 0, "Still no results";
+
+    # add the link
+    $ticket->AddLink( Type => 'RefersTo', Target => "a:$aid" );
+
+    # verify the ticket has it
+    my @links = @{$ticket->RefersTo->ItemsArrayRef};
+    is scalar @links, 1, "Has one RefersTo link";
+    is ref $links[0]->TargetObj, "RT::Article", "Link points to an article";
+    is $links[0]->TargetObj->id, $aid, "Link points to the article we specified";
+
+    # search again
+    $tickets->FromSQL(" RefersTo = 'a:$aid' ");
+    is $tickets->Count, 1, "Found one ticket with short URI";
+
+    # search with the full uri
+    $tickets->FromSQL(" RefersTo = '@{[ $article->URI ]}' ");
+    is $tickets->Count, 1, "Found one ticket with full URI";
+}
index 3ec36dd..03eaa9a 100644 (file)
@@ -12,6 +12,7 @@ Group @WEB_GROUP@
 </IfModule>
 </IfModule>
 
+ServerName localhost
 Listen %%LISTEN%%
 
 ErrorLog "%%LOG_FILE%%"
index 3b1f3f6..20d2f44 100644 (file)
@@ -30,6 +30,7 @@ Group @WEB_GROUP@
 </IfModule>
 </IfModule>
 
+ServerName localhost
 Listen %%LISTEN%%
 
 ErrorLog "%%LOG_FILE%%"
index 6d07b96..79f5f0e 100644 (file)
@@ -1,7 +1,17 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 15;
+BEGIN {
+    require RT::Test;
+
+    if (eval { require GD }) {
+        RT::Test->import(tests => 15);
+    }
+    else {
+        RT::Test->import(skip_all => 'GD required.');
+    }
+}
+
 use utf8;
 
 my $root = RT::Test->load_or_create_user( Name => 'root' );
index 7a7a54c..00cfc6a 100644 (file)
@@ -2,7 +2,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 187;
+use RT::Test tests => 181;
 use Test::Warn;
 use RT::Dashboard::Mailer;
 
@@ -138,17 +138,6 @@ sub delete_dashboard { # {{{
     ok($ok, $msg);
 } # }}}
 
-sub delete_subscriptions { # {{{
-    my $subscription_id = shift;
-    # delete the dashboard and make sure we get exactly one subscription failure
-    # notice
-    my $user = RT::User->new(RT->SystemUser);
-    $user->Load('root');
-    for my $subscription ($user->Attributes->Named('Subscription')) {
-        $subscription->Delete;
-    }
-} # }}}
-
 my $good_time = 1290423660; # 6:01 EST on a monday
 my $bad_time  = 1290427260; # 7:01 EST on a monday
 
@@ -223,21 +212,9 @@ SKIP: {
 
 delete_dashboard($dashboard_id);
 
-warning_like {
-    RT::Dashboard::Mailer->MailDashboards(All => 1);
-} qr/Unable to load dashboard $dashboard_id of subscription $subscription_id for user root/;
-
-@mails = RT::Test->fetch_caught_mails;
-is(@mails, 1, "one mail for subscription failure");
-$mail = parse_mail($mails[0]);
-is($mail->head->get('Subject'), "[example.com] Missing dashboard!\n");
-is($mail->head->get('From'), "dashboard\@example.com\n");
-is($mail->head->get('X-RT-Dashboard-Id'), "$dashboard_id\n");
-is($mail->head->get('X-RT-Dashboard-Subscription-Id'), "$subscription_id\n");
-
 RT::Dashboard::Mailer->MailDashboards(All => 1);
 @mails = RT::Test->fetch_caught_mails;
-is(@mails, 0, "no mail because the subscription notice happens only once");
+is(@mails, 0, "no mail because the subscription is deleted");
 
 RT::Test->stop_server;
 RT::Test->clean_caught_mails;
@@ -277,7 +254,6 @@ RT->Config->Set('EmailDashboardRemove' => (qr/My dashboards/, "Testing!"));
 ($baseurl, $m) = RT::Test->started_ok;
 
 delete_dashboard($dashboard_id);
-delete_subscriptions();
 
 RT::Test->clean_caught_mails;
 
@@ -330,7 +306,6 @@ RT->Config->Set('EmailDashboardRemove' => (qr/My dashboards/, "Testing!"));
 ($baseurl, $m) = RT::Test->started_ok;
 
 delete_dashboard($dashboard_id);
-delete_subscriptions();
 
 RT::Test->clean_caught_mails;
 
@@ -373,7 +348,6 @@ RT->Config->Set('EmailDashboardRemove' => (qr/My dashboards/, "Testing!"));
 ($baseurl, $m) = RT::Test->started_ok;
 
 delete_dashboard($dashboard_id);
-delete_subscriptions();
 
 RT::Test->clean_caught_mails;
 
index 9f0e669..98eabd5 100644 (file)
@@ -57,7 +57,7 @@ use strict;
 use warnings;
 
 
-use RT::Test config => 'Set( $UnsafeEmailCommands, 1);', tests => 221, actual_server => 1;
+use RT::Test config => 'Set( $UnsafeEmailCommands, 1);', tests => 228, actual_server => 1;
 my ($baseurl, $m) = RT::Test->started_ok;
 
 use RT::Tickets;
@@ -608,6 +608,35 @@ EOF
     $m->no_warnings_ok;
 }
 
+diag "make sure we check that UTF-8 is really UTF-8";
+{
+    my $text = <<EOF;
+From: root\@localhost
+To: rtemail\@@{[RT->Config->Get('rtname')]}
+Subject: This is test wrong utf-8 chars
+Content-Type: text/plain; charset="utf-8"
+
+utf-8: informaci\303\263n confidencial
+latin1: informaci\363n confidencial
+
+bye
+EOF
+    my ($status, $id) = RT::Test->send_via_mailgate_and_http($text);
+    is ($status >> 8, 0, "The mail gateway exited normally");
+    ok ($id, "created ticket");
+
+    my $tick = RT::Test->last_ticket;
+    is ($tick->Id, $id, "correct ticket");
+
+    my $content = $tick->Transactions->First->Content;
+    Encode::_utf8_off($content);
+
+    like $content, qr{informaci\303\263n confidencial};
+    like $content, qr{informaci\357\277\275n confidencial};
+
+    $m->no_warnings_ok;
+}
+
 diag "check that mailgate doesn't suffer from empty Reply-To:";
 {
     my $text = <<EOF;
index 7dff16d..a7abeef 100644 (file)
@@ -78,7 +78,11 @@ cmp_deeply( dump_current_and_savepoint('clean'), "current DB equal to savepoint"
     my $shredder = shredder_new();
     $shredder->PutObjects( Objects => $child );
     $shredder->WipeoutAll;
-    cmp_deeply( dump_current_and_savepoint('parent_ticket'), "current DB equal to savepoint");
+
+  TODO: {
+        local $TODO = "Shredder doesn't delete all links and transactions";
+        cmp_deeply( dump_current_and_savepoint('parent_ticket'), "current DB equal to savepoint");
+    }
 
     $shredder->PutObjects( Objects => $parent );
     $shredder->WipeoutAll;
index 092b570..e63eef8 100644 (file)
@@ -34,6 +34,7 @@ use_ok('RT::Tickets');
     my $child = RT::Ticket->new( RT->SystemUser );
     my ($cid) = $child->Create( Subject => 'child', Queue => 1, MemberOf => $pid );
     ok( $cid, "created new ticket" );
+    $_->ApplyTransactionBatch for $parent, $child;
 
     my $plugin = RT::Shredder::Plugin::Tickets->new;
     isa_ok($plugin, 'RT::Shredder::Plugin::Tickets');
@@ -77,6 +78,8 @@ cmp_deeply( dump_current_and_savepoint('clean'), "current DB equal to savepoint"
     my ($status, $msg) = $child->AddLink( Target => $pid, Type => 'DependsOn' );
     ok($status, "added reqursive link") or diag "error: $msg";
 
+    $_->ApplyTransactionBatch for $parent, $child;
+
     my $plugin = RT::Shredder::Plugin::Tickets->new;
     isa_ok($plugin, 'RT::Shredder::Plugin::Tickets');
 
@@ -121,6 +124,8 @@ cmp_deeply( dump_current_and_savepoint('clean'), "current DB equal to savepoint"
     ok( $cid2, "created new ticket" );
     $child2->SetStatus('resolved');
 
+    $_->ApplyTransactionBatch for $parent, $child1, $child2;
+
     my $plugin = RT::Shredder::Plugin::Tickets->new;
     isa_ok($plugin, 'RT::Shredder::Plugin::Tickets');
 
index 4f4ecc8..1f4cb49 100644 (file)
@@ -5,8 +5,8 @@ use warnings;
 
 use Test::Deep;
 use File::Spec;
-use Test::More tests => 9;
-use RT::Test nodb => 1;
+use Test::More tests => 21;
+use RT::Test ();
 BEGIN {
     my $shredder_utils = RT::Test::get_relocatable_file('utils.pl',
         File::Spec->curdir());
@@ -38,3 +38,61 @@ use_ok('RT::Shredder::Plugin::Users');
     ok(!$status, "bad 'status' arg value");
 }
 
+init_db();
+
+RT::Test->set_rights(
+    { Principal => 'Everyone', Right => [qw(CreateTicket)] },
+);
+
+create_savepoint('clean');
+
+{ # Create two users and a ticket. Shred second user and replace relations with first user
+    my ($uidA, $uidB, $msg);
+    my $userA = RT::User->new( RT->SystemUser );
+    ($uidA, $msg) = $userA->Create( Name => 'userA', Privileged => 1, Disabled => 0 );
+    ok( $uidA, "created user A" ) or diag "error: $msg";
+
+    my $userB = RT::User->new( RT->SystemUser );
+    ($uidB, $msg) = $userB->Create( Name => 'userB', Privileged => 1, Disabled => 0 );
+    ok( $uidB, "created user B" ) or diag "error: $msg";
+
+    my ($tid, $trid);
+    my $ticket = RT::Ticket->new( RT::CurrentUser->new($userB) );
+    ($tid, $trid, $msg) = $ticket->Create( Subject => 'UserB Ticket', Queue => 1 );
+    ok( $tid, "created new ticket") or diag "error: $msg";
+
+    my $transaction = RT::Transaction->new( RT->SystemUser );
+    $transaction->Load($trid);
+    is ( $transaction->Creator, $uidB, "ticket creator is user B" );
+
+    my $plugin = RT::Shredder::Plugin::Users->new;
+    isa_ok($plugin, 'RT::Shredder::Plugin::Users');
+
+    my $status;
+    ($status, $msg) = $plugin->TestArgs( status => 'any', name => 'userB', replace_relations => $uidA );
+    ok($status, "plugin arguments are ok") or diag "error: $msg";
+
+    my @objs;
+    ($status, @objs) = $plugin->Run;
+    ok($status, "executed plugin successfully") or diag "error: @objs";
+    @objs = RT::Shredder->CastObjectsToRecords( Objects => \@objs );
+    is(scalar @objs, 1, "one object in the result set");
+
+    my $shredder = shredder_new();
+
+    ($status, $msg) = $plugin->SetResolvers( Shredder => $shredder );
+    ok($status, "set conflicts resolver") or diag "error: $msg";
+
+    $shredder->PutObjects( Objects => \@objs );
+    $shredder->WipeoutAll;
+
+    $ticket->Load( $tid );
+    is($ticket->id, $tid, 'loaded ticket');
+
+    $transaction->Load($trid);
+    is ( $transaction->Creator, $uidA, "ticket creator is now user A" );
+
+    $shredder->Wipeout( Object => $ticket );
+    $shredder->Wipeout( Object => $userA );
+}
+cmp_deeply( dump_current_and_savepoint('clean'), "current DB equal to savepoint");
index 5f5c182..9b848c6 100644 (file)
@@ -283,7 +283,7 @@ sub dump_sqlite
     my $old_fhkn = $dbh->{'FetchHashKeyName'};
     $dbh->{'FetchHashKeyName'} = 'NAME_lc';
 
-    my $sth = $dbh->table_info( '', '', '%', 'TABLE' ) || die $DBI::err;
+    my $sth = $dbh->table_info( '', '%', '%', 'TABLE' ) || die $DBI::err;
     my @tables = keys %{$sth->fetchall_hashref( 'table_name' )};
 
     my $res = {};
index 809450b..cfc7b1c 100644 (file)
@@ -142,8 +142,8 @@ sub run_auto_tests {
 @conditions = (
     'Cc = "not@exist"'       => sub { 0 },
     'Cc != "not@exist"'      => sub { 1 },
-    'Cc IS NULL'             => sub { $_[0] =~ 'c:-;' },
-    'Cc IS NOT NULL'         => sub { $_[0] !~ 'c:-;' },
+    'Cc IS NULL'             => sub { $_[0] =~ /c:-;/ },
+    'Cc IS NOT NULL'         => sub { $_[0] !~ /c:-;/ },
     'Cc = "x@foo.com"'       => sub { $_[0] =~ /c:[^;]*x/ },
     'Cc != "x@foo.com"'      => sub { $_[0] !~ /c:[^;]*x/ },
     'Cc LIKE "@bar.com"'     => sub { $_[0] =~ /c:[^;]*(?:y|z)/ },
@@ -152,8 +152,8 @@ sub run_auto_tests {
 
     'Requestor = "not@exist"'   => sub { 0 },
     'Requestor != "not@exist"'  => sub { 1 },
-    'Requestor IS NULL'         => sub { $_[0] =~ 'r:-;' },
-    'Requestor IS NOT NULL'     => sub { $_[0] !~ 'r:-;' },
+    'Requestor IS NULL'         => sub { $_[0] =~ /r:-;/ },
+    'Requestor IS NOT NULL'     => sub { $_[0] !~ /r:-;/ },
     'Requestor = "x@foo.com"'   => sub { $_[0] =~ /r:[^;]*x/ },
     'Requestor != "x@foo.com"'  => sub { $_[0] !~ /r:[^;]*x/ },
     'Requestor LIKE "@bar.com"' => sub { $_[0] =~ /r:[^;]*(?:y|z)/ },
@@ -174,7 +174,7 @@ sub run_auto_tests {
     'Subject LIKE "ne"'      => sub { 0 },
     'Subject NOT LIKE "ne"'  => sub { 1 },
     'Subject = "r:x;c:y;"'   => sub { $_[0] eq 'r:x;c:y;' },
-    'Subject LIKE "x"'       => sub { $_[0] =~ 'x' },
+    'Subject LIKE "x"'       => sub { $_[0] =~ /x/ },
 );
 
 @tickets = generate_tix();
index 8c75f6c..784cbbe 100644 (file)
@@ -1,10 +1,11 @@
 #!/usr/bin/perl -w
 use strict;
 
-use RT::Test tests => 25;
+use RT::Test tests => 33;
 
 use constant LogoFile => $RT::MasonComponentRoot .'/NoAuth/images/bpslogo.png';
 use constant FaviconFile => $RT::MasonComponentRoot .'/NoAuth/images/favicon.png';
+use constant TextFile => $RT::MasonComponentRoot .'/NoAuth/css/print.css';
 
 my ($baseurl, $m) = RT::Test->started_ok;
 ok $m->login, 'logged in';
@@ -30,9 +31,18 @@ $m->content_contains('Attachments test', 'we have subject on the page');
 $m->content_contains('Some content', 'and content');
 $m->content_contains('Download bpslogo.png', 'page has file name');
 
+open LOGO, "<", LogoFile or die "Can't open logo file: $!";
+binmode LOGO;
+my $logo_contents = do {local $/; <LOGO>};
+close LOGO;
+$m->follow_link_ok({text => "Download bpslogo.png"});
+is($m->content_type, "image/png");
+is($m->content, $logo_contents, "Binary content matches");
+
+$m->back;
 $m->follow_link_ok({text => 'Reply'}, "reply to the ticket");
 $m->form_name('TicketUpdate');
-$m->field('Attach',  LogoFile);
+$m->field('Attach',  TextFile);
 $m->click('AddMoreAttach');
 is($m->status, 200, "request successful");
 
@@ -44,7 +54,16 @@ is($m->status, 200, "request successful");
 
 $m->content_contains('Download bpslogo.png', 'page has file name');
 $m->content_contains('Download favicon.png', 'page has file name');
+$m->content_contains('Download print.css', 'page has file name');
+
+$m->follow_link_ok( { text => 'Download bpslogo.png' } );
+is( $m->response->header('Content-Type'), 'image/png', 'Content-Type of png lacks charset' );
+
+$m->back;
 
+$m->follow_link_ok( { text => 'Download print.css' } );
+is( $m->response->header('Content-Type'),
+    'text/css;charset=UTF-8', 'Content-Type of text has charset' );
 
 diag "test mobile ui";
 $m->get_ok( $baseurl . '/m/ticket/create?Queue=' . $qid );
index 1fed8e6..394daab 100644 (file)
@@ -3,7 +3,7 @@
 use strict;
 use File::Spec ();
 use Test::Expect;
-use RT::Test tests => 303, actual_server => 1;
+use RT::Test tests => 315, actual_server => 1;
 my ($baseurl, $m) = RT::Test->started_ok;
 
 use RT::User;
@@ -480,6 +480,8 @@ expect_like(qr/Merged into ticket #$merge_ticket_A by root/, 'Merge recorded in
         expect_like(qr/Created link $link1_id $reln $link2_id/, 'Linked');
         expect_send("show -s ticket/$link1_id/links", "Checking creation of $reln...");
         expect_like(qr/$display_relns{$reln}: [\w\d\.\-]+:\/\/[\w\d\.]+\/ticket\/$link2_id/, "Created link $reln");
+        expect_send("show ticket/$link1_id/links", "Checking show links without format");
+        expect_like(qr/$display_relns{$reln}: [\w\d\.\-]+:\/\/[\w\d\.]+\/ticket\/$link2_id/, "Found link $reln");
 
         # delete link
         expect_send("link -d $link1_id $reln $link2_id", "Delete $reln...");
index 736be4d..d63956b 100644 (file)
@@ -3,7 +3,7 @@
 use strict;
 use File::Spec ();
 use Test::Expect;
-use RT::Test tests => 14, actual_server => 1;
+use RT::Test tests => 17, actual_server => 1;
 my ($baseurl, $m) = RT::Test->started_ok;
 my $rt_tool_path = "$RT::BinPath/rt";
 
@@ -19,6 +19,11 @@ expect_run(
     prompt => 'rt> ',
     quit => 'quit',
 );
+
+expect_send( q{create -t ticket set foo=bar}, "create ticket with unknown field" );
+expect_like(qr/foo: Unknown field/, 'foo is unknown field');
+expect_like(qr/Could not create ticket/, 'ticket is not created');
+
 expect_send(q{create -t ticket set subject='new ticket' add cc=foo@example.com}, "Creating a ticket...");
 
 expect_like(qr/Ticket \d+ created/, "Created the ticket");
index 6bdefda..8c0eb57 100644 (file)
@@ -53,6 +53,7 @@ RT::Test->clean_caught_mails;
 
 $m->goto_create_ticket( $queue );
 $m->form_name('TicketCreate');
+$m->field('Requestors', 'recipient@example.com');
 $m->field('Subject', 'Encryption test');
 $m->field('Content', 'Some content');
 ok($m->value('Encrypt', 2), "encrypt tick box is checked");
@@ -122,6 +123,7 @@ RT::Test->clean_caught_mails;
 
 $m->goto_create_ticket( $queue );
 $m->form_name('TicketCreate');
+$m->field('Requestors', 'recipient@example.com');
 $m->field('Subject', 'Signing test');
 $m->field('Content', 'Some other content');
 ok(!$m->value('Encrypt', 2), "encrypt tick box is unchecked");
@@ -195,6 +197,7 @@ RT::Test->clean_caught_mails;
 
 $m->goto_create_ticket( $queue );
 $m->form_name('TicketCreate');
+$m->field('Requestors', 'recipient@example.com');
 $m->field('Subject', 'Crypt+Sign test');
 $m->field('Content', 'Some final? content');
 ok($m->value('Encrypt', 2), "encrypt tick box is checked");
@@ -260,6 +263,7 @@ RT::Test->clean_caught_mails;
 
 $m->goto_create_ticket( $queue );
 $m->form_name('TicketCreate');
+$m->field('Requestors', 'recipient@example.com');
 $m->field('Subject', 'Test crypt-off on encrypted queue');
 $m->field('Content', 'Thought you had me figured out didya');
 $m->field(Encrypt => undef, 2); # turn off encryption
index e2a4e91..f4c8fa4 100644 (file)
@@ -2,7 +2,8 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 96, config => 'Set( %FullTextSearch, Enable => 1, Indexed => 0 );';
+use RT::Test tests => undef,
+    config => 'Set( %FullTextSearch, Enable => 1, Indexed => 0 );';
 my ($baseurl, $m) = RT::Test->started_ok;
 my $url = $m->rt_base_url;
 
@@ -57,6 +58,7 @@ ok $two_words_queue && $two_words_queue->id, 'loaded or created a queue';
     is $parser->QueryToSQL("'me'"), "$active AND ( Subject LIKE 'me' )", "correct parsing";
     is $parser->QueryToSQL("owner:me"), "( Owner.id = '__CurrentUser__' ) AND $active", "correct parsing";
     is $parser->QueryToSQL("owner:'me'"), "( Owner = 'me' ) AND $active", "correct parsing";
+    is $parser->QueryToSQL('owner:root@localhost'), "( Owner.EmailAddress = 'root\@localhost' ) AND $active", "Email address as owner";
 
     is $parser->QueryToSQL("resolved me"), "( Owner.id = '__CurrentUser__' ) AND ( Status = 'resolved' )", "correct parsing";
     is $parser->QueryToSQL("resolved active me"), "( Owner.id = '__CurrentUser__' ) AND ( Status = 'resolved' OR Status = 'new' OR Status = 'open' OR Status = 'stalled' )", "correct parsing";
@@ -217,3 +219,5 @@ for my $quote ( q{'}, q{"} ) {
     }
 }
 
+undef $m;
+done_testing;
index a3b9765..f583d64 100644 (file)
@@ -11,6 +11,9 @@ $lifecycles->{foo} = {
 
 };
 
+# explicitly Set so RT::Test can catch our change
+RT->Config->Set( Lifecycles => %$lifecycles );
+
 RT::Lifecycle->FillCache();
 
 my $general = RT::Test->load_or_create_queue( Name => 'General' );
index 1efc9a5..a1a3ce8 100644 (file)
@@ -1,7 +1,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 16;
+use RT::Test tests => 30;
 my ( $baseurl, $m ) = RT::Test->started_ok;
 
 RT::Test->create_tickets(
@@ -19,4 +19,58 @@ $m->content_contains( 'Show Results',   "has page menu" );
 $m->title_is( 'Found 1 ticket', 'title' );
 $m->content_contains( 'ticket foo', 'has ticket foo' );
 
+# Test searches on custom fields
+my $cf1 = RT::Test->load_or_create_custom_field(
+                      Name  => 'Location',
+                      Queue => 'General',
+                      Type  => 'FreeformSingle', );
+isa_ok( $cf1, 'RT::CustomField' );
+
+my $cf2 = RT::Test->load_or_create_custom_field(
+                      Name  => 'Server-name',
+                      Queue => 'General',
+                      Type  => 'FreeformSingle', );
+isa_ok( $cf2, 'RT::CustomField' );
+
+my $t = RT::Ticket->new(RT->SystemUser);
+
+{
+  my ($id,undef,$msg) = $t->Create(
+            Queue => 'General',
+            Subject => 'Test searching CFs');
+  ok( $id, "Created ticket - $msg" );
+}
+
+{
+  my ($status, $msg) = $t->AddCustomFieldValue(
+                           Field => $cf1->id,
+                          Value => 'Downtown');
+  ok( $status, "Added CF value - $msg" );
+}
+
+{
+  my ($status, $msg) = $t->AddCustomFieldValue(
+                           Field => $cf2->id,
+                          Value => 'Proxy');
+  ok( $status, "Added CF value - $msg" );
+}
+
+# Regular search
+my $search = 'cf.Location:Downtown';
+$m->get_ok("/Search/Simple.html?q=$search");
+$m->title_is( 'Found 1 ticket', 'Found 1 ticket' );
+$m->text_contains( 'Test searching CFs', "Found test CF ticket with $search" );
+
+# Case insensitive
+$search = "cf.Location:downtown";
+$m->get_ok("/Search/Simple.html?q=$search");
+$m->title_is( 'Found 1 ticket', 'Found 1 ticket' );
+$m->text_contains( 'Test searching CFs', "Found test CF ticket with $search" );
+
+# With dash in CF name
+$search = "cf.Server-name:Proxy";
+$m->get_ok("/Search/Simple.html?q=$search");
+$m->title_is( 'Found 1 ticket', 'Found 1 ticket' );
+$m->text_contains( 'Test searching CFs', "Found test CF ticket with $search" );
+
 # TODO more simple search tests
index c9dd7e7..2f0c4d1 100644 (file)
@@ -1,7 +1,7 @@
 use strict;
 use warnings;
 
-use RT::Test tests => 15;
+use RT::Test tests => 22;
 
 my $ticket = RT::Test->create_ticket(
     Subject => 'test bulk update',
@@ -40,5 +40,44 @@ $m->click('SubmitTicket');
 $m->form_name('TicketModifyAll');
 is($m->value('Owner'), 'root', 'owner was successfully changed to root');
 
-# XXX TODO test other parts, i.e. basic, dates, people and links
+$m->get_ok($url . "/Ticket/ModifyAll.html?id=" . $ticket->id);
 
+$m->form_name('TicketModifyAll');
+$m->field('Starts_Date' => "2013-01-01 00:00:00");
+$m->click('SubmitTicket');
+$m->text_contains("Starts: (Tue Jan 01 00:00:00 2013)", 'start date successfully updated');
+
+$m->form_name('TicketModifyAll');
+$m->field('Started_Date' => "2014-01-01 00:00:00");
+$m->click('SubmitTicket');
+$m->text_contains("Started: (Wed Jan 01 00:00:00 2014)", 'started date successfully updated');
+
+$m->form_name('TicketModifyAll');
+$m->field('Told_Date' => "2015-01-01 00:00:00");
+$m->click('SubmitTicket');
+$m->text_contains("Last Contact:  (Thu Jan 01 00:00:00 2015)", 'told date successfully updated');
+
+$m->form_name('TicketModifyAll');
+$m->field('Due_Date' => "2016-01-01 00:00:00");
+$m->click('SubmitTicket');
+$m->text_contains("Due: (Fri Jan 01 00:00:00 2016)", 'due date successfully updated');
+
+$m->get( $url . '/Ticket/ModifyAll.html?id=' . $ticket->id );
+$m->form_name('TicketModifyAll');
+$m->field(WatcherTypeEmail => 'Requestor');
+$m->field(WatcherAddressEmail => 'root@localhost');
+$m->click('SubmitTicket');
+$m->text_contains(
+    "Added principal as a Requestor for this ticket",
+    'watcher is added',
+);
+$m->form_name('TicketModifyAll');
+$m->field(WatcherTypeEmail => 'Requestor');
+$m->field(WatcherAddressEmail => 'root@localhost');
+$m->click('SubmitTicket');
+$m->text_contains(
+    "That principal is already a Requestor for this ticket",
+    'no duplicate watchers',
+);
+
+# XXX TODO test other parts, i.e. links
index ae04e1f..12d01fb 100644 (file)
@@ -12,7 +12,14 @@ my ($val, $msg) =$s1->Create( Queue => $q->Id,
              ScripAction       => 'User Defined',
              CustomIsApplicableCode => 'return ($self->TransactionObj->Field||"") eq "TimeEstimated"',
              CustomPrepareCode => 'return 1',
-             CustomCommitCode  => '$self->TicketObj->SetPriority($self->TicketObj->Priority + 2); return 1;',
+             CustomCommitCode  => '
+if ( $self->TicketObj->CurrentUser->Name ne "RT_System" ) { 
+    warn "Ticket obj has incorrect CurrentUser (should be RT_System) ".$self->TicketObj->CurrentUser->Name
+}
+if ( $self->TicketObj->QueueObj->CurrentUser->Name ne "RT_System" ) { 
+    warn "Queue obj has incorrect CurrentUser (should be RT_System) ".$self->TicketObj->QueueObj->CurrentUser->Name
+}
+$self->TicketObj->SetPriority($self->TicketObj->Priority + 2); return 1;',
              Template          => 'Blank',
              Stage             => 'TransactionBatch',
     );