summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcvs2git <cvs2git>2010-11-05 19:05:57 +0000
committercvs2git <cvs2git>2010-11-05 19:05:57 +0000
commitaaf8baf3662e16e9414de236a39f8801a8c41b01 (patch)
tree2cda603e4311b3e80f79b93d9bcce3a7c7c2d053
parent995a145c931164347683071c95c6754379d36604 (diff)
parent9b2de4257b6a2877434008188e52b8ef71ff339d (diff)
This commit was manufactured by cvs2svn to create branch
'FREESIDE_2_1_BRANCH'.
-rw-r--r--ChangeLog2381
-rw-r--r--FS/FS.pm8
-rw-r--r--FS/FS/AccessRight.pm19
-rw-r--r--FS/FS/ClientAPI/Agent.pm27
-rw-r--r--FS/FS/ClientAPI/MyAccount.pm23
-rw-r--r--FS/FS/ClientAPI/Signup.pm188
-rw-r--r--FS/FS/ClientAPI_XMLRPC.pm3
-rw-r--r--FS/FS/Conf.pm152
-rw-r--r--FS/FS/ConfDefaults.pm12
-rw-r--r--FS/FS/Cron/alert_expiration.pm1
-rw-r--r--FS/FS/Cron/backup.pm6
-rw-r--r--FS/FS/Cron/bill.pm21
-rw-r--r--FS/FS/Cron/notify.pm8
-rw-r--r--FS/FS/Maestro.pm279
-rw-r--r--FS/FS/Mason.pm32
-rw-r--r--FS/FS/Mason/Request.pm28
-rw-r--r--FS/FS/Misc.pm50
-rw-r--r--FS/FS/Record.pm149
-rw-r--r--FS/FS/Schema.pm85
-rw-r--r--FS/FS/Upgrade.pm84
-rw-r--r--FS/FS/access_right.pm35
-rw-r--r--FS/FS/access_user.pm28
-rw-r--r--FS/FS/acct_snarf.pm89
-rw-r--r--FS/FS/agent_type.pm14
-rw-r--r--FS/FS/banned_pay.pm5
-rw-r--r--FS/FS/cdr.pm25
-rw-r--r--FS/FS/cdr/cia.pm39
-rw-r--r--FS/FS/cdr/infinite.pm41
-rw-r--r--FS/FS/contact.pm133
-rw-r--r--FS/FS/contact_phone.pm2
-rw-r--r--FS/FS/cust_bill.pm130
-rw-r--r--FS/FS/cust_bill_pkg_detail.pm93
-rw-r--r--FS/FS/cust_bill_pkg_display.pm2
-rw-r--r--FS/FS/cust_credit.pm21
-rw-r--r--FS/FS/cust_credit_bill_pkg.pm19
-rw-r--r--FS/FS/cust_location.pm80
-rw-r--r--FS/FS/cust_main.pm5591
-rw-r--r--FS/FS/cust_main/Billing.pm2256
-rw-r--r--FS/FS/cust_main/Billing_Realtime.pm1459
-rw-r--r--FS/FS/cust_main/Import.pm53
-rw-r--r--FS/FS/cust_main/Packages.pm452
-rw-r--r--FS/FS/cust_main/Search.pm881
-rw-r--r--FS/FS/cust_main/_Marketgear.pm146
-rw-r--r--FS/FS/cust_main_Mixin.pm210
-rw-r--r--FS/FS/cust_main_county.pm5
-rw-r--r--FS/FS/cust_pay.pm148
-rw-r--r--FS/FS/cust_pay_void.pm53
-rw-r--r--FS/FS/cust_pkg.pm155
-rw-r--r--FS/FS/cust_pkg/Import.pm373
-rw-r--r--FS/FS/cust_pkg_reason.pm14
-rw-r--r--FS/FS/cust_refund.pm10
-rw-r--r--FS/FS/cust_svc.pm43
-rw-r--r--FS/FS/discount.pm11
-rw-r--r--FS/FS/domain_record.pm39
-rw-r--r--FS/FS/geocode_Mixin.pm163
-rw-r--r--FS/FS/msg_template.pm96
-rw-r--r--FS/FS/option_Common.pm29
-rw-r--r--FS/FS/otaker_Mixin.pm2
-rw-r--r--FS/FS/part_event/Action/notice.pm2
-rw-r--r--FS/FS/part_event/Action/notice_to.pm55
-rw-r--r--FS/FS/part_event/Condition.pm22
-rw-r--r--FS/FS/part_event/Condition/balance_age.pm2
-rw-r--r--FS/FS/part_event/Condition/balance_credit_limit.pm32
-rw-r--r--FS/FS/part_event/Condition/cust_bill_past_due.pm41
-rw-r--r--FS/FS/part_event/Condition/cust_status.pm8
-rw-r--r--FS/FS/part_event/Condition/has_referral_custnum.pm10
-rw-r--r--FS/FS/part_event/Condition/once_every.pm46
-rw-r--r--FS/FS/part_event/Condition/once_perinv.pm57
-rw-r--r--FS/FS/part_event/Condition/payby.pm16
-rw-r--r--FS/FS/part_event/Condition/pkg_age.pm16
-rw-r--r--FS/FS/part_event/Condition/pkg_next_bill_within.pm51
-rw-r--r--FS/FS/part_event/Condition/pkg_recurring.pm9
-rw-r--r--FS/FS/part_event/Condition/pkg_status.pm7
-rw-r--r--FS/FS/part_export/acct_http.pm63
-rw-r--r--FS/FS/part_export/communigate_pro.pm83
-rw-r--r--FS/FS/part_export/cust_http.pm66
-rw-r--r--FS/FS/part_export/domain_sql.pm3
-rw-r--r--FS/FS/part_export/domreg_opensrs.pm2
-rw-r--r--FS/FS/part_export/http.pm13
-rw-r--r--FS/FS/part_export/rt_ticket.pm214
-rw-r--r--FS/FS/part_export/shellcommands.pm18
-rw-r--r--FS/FS/part_export/sqlradius.pm141
-rw-r--r--FS/FS/part_pkg.pm40
-rw-r--r--FS/FS/part_pkg/agent.pm6
-rw-r--r--FS/FS/part_pkg/bulk.pm43
-rw-r--r--FS/FS/part_pkg/cdr_termination.pm5
-rw-r--r--FS/FS/part_pkg/discount_Mixin.pm128
-rw-r--r--FS/FS/part_pkg/flat.pm113
-rw-r--r--FS/FS/part_pkg/prorate.pm43
-rw-r--r--FS/FS/part_pkg/prorate_Mixin.pm94
-rw-r--r--FS/FS/part_pkg/recur_Common.pm55
-rw-r--r--FS/FS/part_pkg/sql_external.pm6
-rw-r--r--FS/FS/part_pkg/subscription.pm2
-rw-r--r--FS/FS/part_pkg/voip_cdr.pm194
-rw-r--r--FS/FS/part_pkg/voip_inbound.pm6
-rw-r--r--FS/FS/part_pkg_discount.pm129
-rw-r--r--FS/FS/part_pkg_option.pm4
-rw-r--r--FS/FS/pay_batch.pm53
-rw-r--r--FS/FS/pay_batch/paymentech.pm27
-rw-r--r--FS/FS/rate_time_interval.pm2
-rw-r--r--FS/FS/svc_Common.pm36
-rw-r--r--FS/FS/svc_acct.pm78
-rwxr-xr-xFS/FS/svc_broadband.pm120
-rw-r--r--FS/FS/svc_domain.pm62
-rw-r--r--FS/FS/svc_pbx.pm53
-rw-r--r--FS/FS/svc_phone.pm2
-rw-r--r--FS/MANIFEST9
-rwxr-xr-xFS/bin/freeside-daily2
-rwxr-xr-xFS/bin/freeside-monthly2
-rw-r--r--FS/bin/freeside-prepaidd15
-rw-r--r--FS/bin/freeside-queued2
-rw-r--r--FS/bin/freeside-radgroup8
-rwxr-xr-xFS/bin/freeside-selfservice-xmlrpcd2
-rwxr-xr-xFS/bin/freeside-upgrade27
-rwxr-xr-xFS/bin/freeside-wipe-cvv87
-rw-r--r--FS/t/part_pkg_discount.t5
-rw-r--r--Makefile6
-rwxr-xr-xbin/19add20
-rwxr-xr-xbin/19commit26
-rwxr-xr-xbin/19diff12
-rwxr-xr-xbin/cdr-mysql.import88
-rwxr-xr-xbin/cust_main-find_bogus_geocode36
-rw-r--r--bin/freeside-backup42
-rwxr-xr-xbin/generate-table-module13
-rw-r--r--bin/merge-referrals20
-rwxr-xr-xbin/opensrs_domain_pkgs18
-rwxr-xr-xbin/rt-trim-whitespace38
-rw-r--r--bin/select-cust-desync_bill_dates.sql9
-rw-r--r--bin/test_scrub12
-rwxr-xr-xbin/test_scrub_sql58
-rw-r--r--bin/wipe-customers30
-rwxr-xr-xbin/xmlrpc-customer_status.pl23
-rwxr-xr-xbin/xmlrpc-order_pkg.pl31
-rw-r--r--conf/invoice_latex_statement2
-rw-r--r--conf/invoice_print_pdf0
-rw-r--r--conf/svc_acct-disable_access_number0
-rw-r--r--conf/welcome_letter4
-rw-r--r--debian/changelog6
-rw-r--r--eg/cdr_template.pm32
-rw-r--r--etc/sql-reserved-words.txt113
-rw-r--r--fs_selfservice/FS-SelfService/SelfService.pm12
-rw-r--r--fs_selfservice/FS-SelfService/cgi/discount_term.html17
-rw-r--r--fs_selfservice/FS-SelfService/cgi/make_ach_payment.html1
-rw-r--r--fs_selfservice/FS-SelfService/cgi/make_payment.html4
-rw-r--r--fs_selfservice/FS-SelfService/cgi/myaccount.html10
-rw-r--r--fs_selfservice/FS-SelfService/cgi/selfservice.cgi24
-rwxr-xr-xfs_selfservice/FS-SelfService/cgi/signup.html7
-rw-r--r--fs_selfservice/drupal/admin.inc56
-rw-r--r--fs_selfservice/drupal/freeside.class.php33
-rw-r--r--fs_selfservice/drupal/freeside.info3
-rw-r--r--fs_selfservice/drupal/freeside.module32
-rw-r--r--fs_selfservice/drupal/signup.inc354
-rw-r--r--fs_selfservice/php/freeside_order_pkg_example.php38
-rw-r--r--htetc/freeside-rt.conf46
-rw-r--r--htetc/handler.pl9
-rw-r--r--httemplate/browse/acct_snarf.html78
-rw-r--r--httemplate/browse/cgp_rule.html3
-rw-r--r--httemplate/browse/msg_template.html2
-rw-r--r--httemplate/browse/part_event.html2
-rwxr-xr-xhttemplate/browse/part_pkg.cgi59
-rw-r--r--httemplate/docs/credits.html1
-rwxr-xr-xhttemplate/edit/REAL_cust_pkg.cgi10
-rw-r--r--httemplate/edit/access_user.html18
-rw-r--r--httemplate/edit/acct_snarf.html50
-rw-r--r--httemplate/edit/bulk-cust_pkg.html60
-rw-r--r--httemplate/edit/cgp_rule-redirect_all.html52
-rw-r--r--httemplate/edit/cgp_rule-vacation.html45
-rwxr-xr-xhttemplate/edit/cust_main.cgi15
-rw-r--r--httemplate/edit/cust_main/billing.html14
-rw-r--r--httemplate/edit/cust_main/bottomfixup.js253
-rw-r--r--httemplate/edit/cust_main/contact.html6
-rw-r--r--httemplate/edit/cust_main/first_pkg.html34
-rw-r--r--httemplate/edit/cust_main/first_pkg/select-part_pkg.html4
-rw-r--r--httemplate/edit/cust_main/top_misc.html32
-rwxr-xr-xhttemplate/edit/cust_pay.cgi16
-rw-r--r--httemplate/edit/cust_pay_pending.html29
-rwxr-xr-xhttemplate/edit/cust_refund.cgi14
-rw-r--r--httemplate/edit/domain_record.html53
-rw-r--r--httemplate/edit/elements/edit.html6
-rw-r--r--httemplate/edit/msg_template.html21
-rw-r--r--httemplate/edit/part_export.cgi20
-rwxr-xr-xhttemplate/edit/part_pkg.cgi72
-rwxr-xr-xhttemplate/edit/part_svc.cgi12
-rwxr-xr-xhttemplate/edit/process/REAL_cust_pkg.cgi2
-rw-r--r--httemplate/edit/process/access_user.html10
-rw-r--r--httemplate/edit/process/acct_snarf.html20
-rw-r--r--httemplate/edit/process/bulk-cust_pkg.cgi9
-rw-r--r--httemplate/edit/process/cgp_rule-redirect_all.html24
-rw-r--r--httemplate/edit/process/cgp_rule-simplified.html53
-rw-r--r--httemplate/edit/process/cgp_rule-vacation.html29
-rwxr-xr-xhttemplate/edit/process/cust_main.cgi9
-rwxr-xr-xhttemplate/edit/process/cust_pay.cgi12
-rwxr-xr-xhttemplate/edit/process/cust_refund.cgi15
-rwxr-xr-xhttemplate/edit/process/domain_record.cgi19
-rwxr-xr-xhttemplate/edit/process/part_pkg.cgi8
-rw-r--r--httemplate/edit/process/prospect_main.html7
-rw-r--r--httemplate/edit/process/quick-cust_pkg.cgi6
-rw-r--r--httemplate/edit/process/rate_time.cgi3
-rwxr-xr-xhttemplate/edit/process/svc_acct.cgi2
-rw-r--r--httemplate/edit/process/svc_domain-defaultrecords.cgi18
-rw-r--r--httemplate/edit/prospect_main-ocr.html86
-rw-r--r--httemplate/edit/prospect_main-upload.html7
-rw-r--r--httemplate/edit/prospect_main.html44
-rw-r--r--httemplate/edit/rate_time.cgi4
-rwxr-xr-xhttemplate/edit/svc_acct.cgi222
-rw-r--r--httemplate/edit/svc_acct/communigate.html249
-rwxr-xr-xhttemplate/edit/svc_domain.cgi272
-rw-r--r--httemplate/edit/svc_domain/communigate-acct_defaults.html223
-rw-r--r--httemplate/edit/svc_domain/communigate-basics.html82
-rw-r--r--httemplate/elements/city.html2
-rw-r--r--httemplate/elements/contact.html55
-rw-r--r--httemplate/elements/customer-table.html13
-rw-r--r--httemplate/elements/email-link.html16
-rw-r--r--httemplate/elements/freeside.css53
-rw-r--r--httemplate/elements/header.html5
-rw-r--r--httemplate/elements/input-date-field.html50
-rw-r--r--httemplate/elements/menu.html23
-rw-r--r--httemplate/elements/popup_link.html2
-rw-r--r--httemplate/elements/search-cust_main.html5
-rw-r--r--httemplate/elements/select-discount_term.html32
-rw-r--r--httemplate/elements/select-month_year.html2
-rw-r--r--httemplate/elements/select-state.html2
-rw-r--r--httemplate/elements/select-terms.html2
-rw-r--r--httemplate/elements/select-user.html21
-rw-r--r--httemplate/elements/standardize_locations.html18
-rw-r--r--httemplate/elements/standardize_locations.js278
-rw-r--r--httemplate/elements/table-grid.html7
-rw-r--r--httemplate/elements/tr-pkg_svc.html2
-rw-r--r--httemplate/elements/tr-select-cust_tag.html3
-rw-r--r--httemplate/elements/tr-select-discount_term.html25
-rw-r--r--httemplate/elements/xmlhttp.html6
-rw-r--r--httemplate/graph/money_time.cgi2
-rw-r--r--httemplate/misc/batch-cust_pay.html98
-rw-r--r--httemplate/misc/cancel_cust.html42
-rwxr-xr-xhttemplate/misc/change_pkg.cgi12
-rw-r--r--httemplate/misc/choose_tax_location.html90
-rwxr-xr-xhttemplate/misc/cust_main-cancel.cgi31
-rw-r--r--httemplate/misc/cust_main-import.cgi17
-rw-r--r--httemplate/misc/cust_main-import_charges.cgi63
-rwxr-xr-xhttemplate/misc/cust_main-merge.html40
-rw-r--r--httemplate/misc/cust_main_note-import.cgi8
-rw-r--r--httemplate/misc/cust_main_note-import.html12
-rw-r--r--httemplate/misc/cust_pkg-import.html150
-rw-r--r--httemplate/misc/custom_link_proxy.cgi24
-rwxr-xr-xhttemplate/misc/delete-domain_record.cgi2
-rw-r--r--httemplate/misc/email-customers.html109
-rw-r--r--httemplate/misc/merge_cust.html72
-rw-r--r--httemplate/misc/order_pkg.html60
-rw-r--r--httemplate/misc/payment.cgi5
-rw-r--r--httemplate/misc/process/batch-cust_pay.cgi34
-rw-r--r--httemplate/misc/process/cust_main-import_charges.cgi3
-rw-r--r--httemplate/misc/process/cust_main_note-import.cgi41
-rw-r--r--httemplate/misc/process/cust_pay-import.cgi2
-rw-r--r--httemplate/misc/process/cust_pkg-import.html10
-rwxr-xr-xhttemplate/misc/process/delete-customer.cgi2
-rw-r--r--httemplate/misc/process/email-customers.html2
-rw-r--r--httemplate/misc/process/payment.cgi26
-rwxr-xr-xhttemplate/misc/timeworked.html24
-rwxr-xr-xhttemplate/misc/unprovision.cgi32
-rw-r--r--httemplate/misc/xmlhttp-cust_main-address_standardize.html1
-rw-r--r--httemplate/misc/xmlhttp-cust_main-censustract.html21
-rw-r--r--httemplate/misc/xmlhttp-cust_main-discount_terms.cgi24
-rw-r--r--httemplate/misc/xmlhttp-cust_main-search.cgi8
-rwxr-xr-xhttemplate/search/477.html8
-rwxr-xr-xhttemplate/search/477partIA_detail.html65
-rwxr-xr-xhttemplate/search/477partIA_summary.html9
-rwxr-xr-xhttemplate/search/477partVI_census.html (renamed from httemplate/search/477partVI.html)16
-rw-r--r--httemplate/search/cdr.html50
-rwxr-xr-xhttemplate/search/cust_bill.html12
-rw-r--r--httemplate/search/cust_bill_pkg.cgi21
-rw-r--r--httemplate/search/cust_bill_pkg_discount.html30
-rwxr-xr-xhttemplate/search/cust_credit.html148
-rwxr-xr-xhttemplate/search/cust_main.cgi10
-rwxr-xr-xhttemplate/search/cust_main.html12
-rwxr-xr-xhttemplate/search/cust_pay.html (renamed from httemplate/search/cust_pay.cgi)0
-rwxr-xr-xhttemplate/search/cust_pay_pending.html2
-rwxr-xr-xhttemplate/search/cust_pkg.cgi14
-rw-r--r--httemplate/search/cust_pkg_discount.html6
-rw-r--r--httemplate/search/cust_pkg_summary.cgi87
-rw-r--r--httemplate/search/cust_pkg_summary.html24
-rw-r--r--httemplate/search/cust_pkg_susp.cgi107
-rw-r--r--httemplate/search/cust_pkg_susp.html24
-rw-r--r--httemplate/search/cust_pkg_svc.html117
-rw-r--r--httemplate/search/elements/cust_main_dayranges.html6
-rwxr-xr-xhttemplate/search/elements/cust_pay_or_refund.html142
-rw-r--r--httemplate/search/elements/report_cust_pay_or_refund.html149
-rw-r--r--httemplate/search/elements/search-xml.html1
-rwxr-xr-xhttemplate/search/report_477.html2
-rw-r--r--httemplate/search/report_cdr.html84
-rw-r--r--httemplate/search/report_cust_bill.html7
-rw-r--r--httemplate/search/report_cust_bill_pkg_discount.html13
-rw-r--r--httemplate/search/report_cust_credit.html21
-rwxr-xr-xhttemplate/search/report_cust_main.html22
-rw-r--r--httemplate/search/report_cust_pay.html121
-rwxr-xr-xhttemplate/search/report_cust_pkg.html3
-rw-r--r--httemplate/search/report_cust_pkg_discount.html13
-rw-r--r--httemplate/search/report_cust_refund.html121
-rw-r--r--httemplate/search/report_h_cust_pay.html2
-rwxr-xr-xhttemplate/search/report_receivables.html19
-rwxr-xr-xhttemplate/search/report_svc_broadband.html100
-rw-r--r--httemplate/search/rt_transaction.html24
-rwxr-xr-xhttemplate/search/svc_acct.cgi6
-rwxr-xr-xhttemplate/search/svc_broadband.cgi75
-rwxr-xr-xhttemplate/view/cust_bill.cgi19
-rwxr-xr-xhttemplate/view/cust_main.cgi30
-rw-r--r--httemplate/view/cust_main/billing.html10
-rw-r--r--httemplate/view/cust_main/contacts.html14
-rw-r--r--httemplate/view/cust_main/custom.html21
-rwxr-xr-xhttemplate/view/cust_main/packages.html5
-rw-r--r--httemplate/view/cust_main/packages/package.html1
-rw-r--r--httemplate/view/cust_main/packages/services.html44
-rw-r--r--httemplate/view/cust_main/packages/status.html6
-rw-r--r--httemplate/view/cust_main/payment_history.html44
-rw-r--r--httemplate/view/cust_main/payment_history/attempted_payment.html41
-rw-r--r--httemplate/view/cust_main/payment_history/payment.html5
-rw-r--r--httemplate/view/cust_main/payment_history/pending_payment.html61
-rw-r--r--httemplate/view/cust_main/payment_history/voided_payment.html8
-rw-r--r--httemplate/view/cust_pay.html34
-rw-r--r--httemplate/view/elements/svc_Common.html16
-rw-r--r--httemplate/view/image.cgi31
-rw-r--r--httemplate/view/svc_acct/communigate.html39
-rw-r--r--httemplate/view/svc_domain/acct_defaults.html8
-rw-r--r--httemplate/view/svc_domain/dns.html93
-rw-r--r--httemplate/view/svc_pbx.cgi72
-rw-r--r--httemplate/view/svc_phone.cgi17
-rw-r--r--rpm/freeside.spec2
-rw-r--r--rt/FREESIDE_MODIFIED16
-rwxr-xr-xrt/etc/schema.Pg1
-rwxr-xr-xrt/etc/schema.mysql-4.11
-rw-r--r--rt/lib/RT/CustomField.pm3
-rw-r--r--rt/lib/RT/Ticket_Overlay.pm10
-rw-r--r--rt/lib/RT/Tickets_Overlay.pm8
-rw-r--r--rt/lib/RT/URI/freeside/Internal.pm4
-rw-r--r--rt/share/html/Admin/CustomFields/Modify.html23
-rw-r--r--rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Elements/Tabs/Default12
-rw-r--r--rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Modify.html/BeforeActionList15
-rw-r--r--rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Update.html/BeforeDisplay24
-rw-r--r--rt/share/html/Elements/AddCustomers5
-rw-r--r--rt/share/html/Elements/ColumnMap7
-rwxr-xr-xrt/share/html/Elements/Header6
-rw-r--r--rt/share/html/Elements/RT__CustomField/ColumnMap4
-rw-r--r--rt/share/html/Elements/RefreshHomepage6
-rw-r--r--rt/share/html/Ticket/Elements/AddCustomers5
-rw-r--r--rt/share/html/Ticket/Elements/CheckMandatoryFields9
-rw-r--r--rt/share/html/Ticket/Graphs/index.html4
-rwxr-xr-xrt/share/html/autohandler4
346 files changed, 19045 insertions, 7764 deletions
diff --git a/ChangeLog b/ChangeLog
index 1a98b44..b10e13f 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,2245 @@
+2010-09-29 15:47 ivan
+
+ * Makefile: 2.1.1!
+
+2010-09-29 14:14 ivan
+
+ * ChangeLog: 2.1.1
+
+2010-09-29 12:54 ivan
+
+ * httemplate/search/rt_transaction.html: fix time worked search by
+ customer
+
+2010-09-29 12:38 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm: fix
+ noskip_dst_length_accountcode_tollfree, RT#9683
+
+2010-09-29 11:39 mark
+
+ * FS/FS/cust_main.pm: restore missing signup dates during upgrade,
+ RT#9972
+
+2010-09-28 22:40 mark
+
+ * FS/FS/ClientAPI/Signup.pm, fs_selfservice/drupal/signup.inc:
+ svc_pbx in signup server, RT#9380
+
+2010-09-28 17:50 ivan
+
+ * bin/test_scrub_sql: adding tool to drop things with sql
+ statements on a db
+
+2010-09-28 17:38 mark
+
+ * httemplate/edit/process/cust_main.cgi: avoid wiping signupdate
+ when editing cust_main, RT#9972
+
+2010-09-28 16:12 mark
+
+ * FS/FS/Conf.pm, FS/FS/ClientAPI/Signup.pm,
+ fs_selfservice/drupal/admin.inc,
+ fs_selfservice/drupal/signup.inc: assorted drupal self-service
+ fixes, RT#9380
+
+2010-09-27 18:55 mark
+
+ * bin/cdr-mysql.import: mysql cdr import script, RT#10009
+
+2010-09-27 17:26 ivan
+
+ * ChangeLog: 2.1.1
+
+2010-09-27 15:59 mark
+
+ * FS/FS/: cdr.pm, part_pkg/voip_cdr.pm: don't use decimal minutes
+ in call detail format
+
+2010-09-24 17:56 mark
+
+ * FS/FS/: Schema.pm, cdr.pm, part_pkg/voip_cdr.pm: clean up call
+ rating math to avoid premature rounding, RT#9885
+
+2010-09-24 15:08 jeff
+
+ * FS/FS/cust_main/Import.pm, httemplate/misc/cust_main-import.cgi:
+ import tax exempt and force postal invoice optiosn
+
+2010-09-24 15:07 jeff
+
+ * httemplate/misc/process/cust_main_note-import.cgi: import to
+ notes
+
+2010-09-24 10:23 jeff
+
+ * bin/opensrs_domain_pkgs: set the bill date earlier
+
+2010-09-23 20:14 jeff
+
+ * FS/FS/cust_main/Billing.pm: fix bad bug in line item generation
+ RT#10024
+
+2010-09-23 18:04 mark
+
+ * FS/FS/rate_time_interval.pm, httemplate/edit/rate_time.cgi,
+ httemplate/edit/process/rate_time.cgi: fix display of 12:00 hour
+ in time intervals
+
+2010-09-23 16:21 mark
+
+ * httemplate/misc/: xmlhttp-cust_main-search.cgi,
+ process/batch-cust_pay.cgi: fix use of agent_custid in quick
+ payment entry, RT#10035
+
+2010-09-23 14:27 jeff
+
+ * FS/FS/part_pkg.pm: fix bad conflict resolution between ivan's and
+ jeff's idea of how to use a different freq
+
+2010-09-23 11:32 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm: turn debugging off
+
+2010-09-23 11:30 ivan
+
+ * FS/FS/cust_main/Billing.pm: not sure if this is a fix or a
+ workaround, hence the warning, RT#10024
+
+2010-09-23 11:00 ivan
+
+ * FS/FS/cust_main/: Billing.pm: additional debug info, RT#10012
+
+2010-09-23 10:55 ivan
+
+ * FS/FS/cust_main/: Billing.pm, Billing_Realtime.pm, Packages.pm:
+ honor cust_main DEBUG flag, add some additional debug info,
+ RT#10012
+
+2010-09-23 10:46 ivan
+
+ * FS/FS/cust_main/: Billing.pm, Billing_Realtime.pm, Packages.pm:
+ honor cust_main DEBUG flag, add some additional debug info,
+ RT#10012
+
+2010-09-22 16:04 mark
+
+ * FS/FS/msg_template.pm, FS/FS/part_event/Action/notice.pm,
+ FS/FS/part_event/Action/notice_to.pm,
+ httemplate/edit/msg_template.html: event action to send a notice
+ to a fixed address, RT#8209
+
+2010-09-22 15:01 ivan
+
+ * FS/FS/cust_main/Billing.pm: fix fallout from discount work,
+ RT#10025
+
+2010-09-22 14:22 mark
+
+ * FS/FS/Schema.pm, FS/FS/cust_main.pm,
+ FS/FS/part_event/Condition/balance_credit_limit.pm,
+ httemplate/edit/cust_main/billing.html,
+ httemplate/edit/process/cust_main.cgi,
+ httemplate/view/cust_main/billing.html: customer credit limits,
+ RT#8209
+
+2010-09-22 13:46 ivan
+
+ * FS/FS/cust_main/: Billing.pm, Billing_Realtime.pm: use
+ Data::Dumper to fix debugging - hopefully last of fallout from
+ refactoring things into their own fiels
+
+2010-09-22 12:35 mark
+
+ * FS/FS/cdr.pm, FS/FS/cdr/cia.pm, FS/FS/cdr/infinite.pm,
+ httemplate/search/cdr.html: CIA and Infinite Conferencing cdr
+ formats, RT#8788
+
+2010-09-22 12:16 jeff
+
+ * FS/FS/part_pkg_discount.pm, FS/FS/Conf.pm, FS/FS/Mason.pm,
+ FS/FS/Schema.pm, FS/FS/cust_bill.pm,
+ FS/FS/cust_credit_bill_pkg.pm, FS/FS/cust_main_county.pm,
+ FS/FS/cust_pay.pm, FS/FS/cust_pkg.pm, FS/FS/discount.pm,
+ FS/FS/part_pkg.pm, FS/t/part_pkg_discount.t,
+ fs_selfservice/FS-SelfService/cgi/discount_term.html,
+ fs_selfservice/FS-SelfService/cgi/make_ach_payment.html,
+ fs_selfservice/FS-SelfService/cgi/make_payment.html,
+ fs_selfservice/FS-SelfService/cgi/myaccount.html,
+ fs_selfservice/FS-SelfService/cgi/selfservice.cgi,
+ httemplate/elements/customer-table.html,
+ httemplate/elements/select-discount_term.html,
+ httemplate/elements/tr-select-discount_term.html,
+ httemplate/misc/xmlhttp-cust_main-discount_terms.cgi,
+ FS/MANIFEST, FS/FS/ClientAPI/MyAccount.pm,
+ FS/FS/cust_main/Billing.pm, FS/FS/cust_main/Billing_Realtime.pm,
+ FS/FS/part_pkg/flat.pm, httemplate/browse/part_pkg.cgi,
+ httemplate/edit/cust_pay.cgi, httemplate/edit/part_pkg.cgi,
+ httemplate/misc/batch-cust_pay.html, httemplate/misc/payment.cgi,
+ httemplate/edit/process/cust_pay.cgi,
+ httemplate/edit/process/part_pkg.cgi,
+ httemplate/misc/process/batch-cust_pay.cgi,
+ httemplate/misc/process/payment.cgi,
+ httemplate/view/cust_main/packages/package.html: prepayment
+ discounts rt#5318
+
+2010-09-21 23:36 jeff
+
+ * FS/FS/Record.pm: avoid unexpected side effects when using 'op' =>
+ '>' in qsearch
+
+2010-09-21 18:08 ivan
+
+ * FS/FS/cust_main/Billing_Realtime.pm: fix cardtype errors, fallout
+ from refactor for maestro, RT#10012
+
+2010-09-21 17:08 mark
+
+ * httemplate/: elements/menu.html, search/cust_credit.html,
+ search/report_cust_credit.html,
+ search/elements/cust_pay_or_refund.html,
+ search/elements/report_cust_pay_or_refund.html: unapplied
+ payment/refund/credit reports, RT#7503
+
+2010-09-20 22:54 mark
+
+ * FS/FS/: pay_batch.pm, pay_batch/paymentech.pm: store TxRefNum for
+ Paymentech batch payments, RT#9962
+
+2010-09-20 20:56 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm: skip_max_callers meaning was
+ reversed. doh! RT#9907
+
+2010-09-20 20:41 ivan
+
+ * FS/FS/Maestro.pm: add service_status call more like we should
+ have in the first place, RT#9905
+
+2010-09-20 15:45 mark
+
+ * FS/FS/cust_main.pm: fix "payby" option to batch_card
+
+2010-09-20 14:26 ivan
+
+ * fs_selfservice/php/freeside_order_pkg_example.php: adding
+
+2010-09-20 14:13 ivan
+
+ * FS/FS/Maestro.pm, bin/xmlrpc-customer_status.pl,
+ bin/xmlrpc-order_pkg.pl: Maestro.order_pkg API
+
+2010-09-20 13:29 ivan
+
+ * FS/: FS.pm, MANIFEST, FS/cust_main.pm, FS/cust_main/Billing.pm,
+ FS/cust_main/Billing_Realtime.pm, FS/cust_main/Packages.pm,
+ FS/cust_main/_Marketgear.pm: last of the refatoring giant
+ cust_main.pm for now, RT#9967
+
+2010-09-20 12:55 ivan
+
+ * FS/FS/cust_main/Billing.pm: apply_payments/apply_credits fixes
+ from moving them to Billing.pm
+
+2010-09-18 22:55 ivan
+
+ * FS/FS/cust_pay.pm: fix a series of unfortunate upgrades which
+ resulted in too much payment receiptery, RT#9723
+
+2010-09-18 22:50 ivan
+
+ * FS/: FS/Conf.pm, FS/Upgrade.pm, FS/cust_pay.pm,
+ bin/freeside-upgrade: fix a series of unfortunate upgrades which
+ resulted in too much payment receiptery, RT#9723
+
+2010-09-18 20:02 ivan
+
+ * FS/FS/part_event/: Condition.pm, Condition/payby.pm: another
+ (hopefully significant) billing optimization, RT#6802
+
+2010-09-18 17:37 ivan
+
+ * FS/FS/cust_main.pm: restore mistakenly removed fuzzyfile
+ disabiling in _upgrade_data
+
+2010-09-18 17:13 ivan
+
+ * FS/FS/: cust_main.pm, Cron/bill.pm, cust_main/Billing.pm,
+ cust_main/Billing_Realtime.pm: should speed up billing (well,
+ event checking) significantly by eliminating unnecessary target
+ objects one level up in the loop, RT#6802
+
+2010-09-18 12:10 ivan
+
+ * FS/FS/cust_main.pm: fix fuzzyfile foo
+
+2010-09-18 08:57 ivan
+
+ * FS/FS/otaker_Mixin.pm: fix stupid cust_main_note upgrade for
+ multi-word first names
+
+2010-09-18 01:14 ivan
+
+ * FS/FS/cust_pay.pm: YA otaker upgrade kludge for old datasets:
+ cust_pay.payby COMP
+
+2010-09-18 00:53 ivan
+
+ * FS/FS/cust_credit.pm: YA otaker upgrade kludge for old datasets:
+ empty cust_credit.creasonnum
+
+2010-09-18 00:28 ivan
+
+ * FS/FS/cust_pkg_reason.pm: don't abort upgrade for want of not
+ being able to fill in cust_pkg_reason.action when doing the
+ otaker replace
+
+2010-09-17 21:28 mark
+
+ * FS/FS/part_event/Condition/cust_bill_past_due.pm: invoice past
+ due event, RT#9931
+
+2010-09-17 21:26 ivan
+
+ * FS/FS/cust_main.pm: cust_main otaker upgrade vs banned cards
+
+2010-09-17 21:25 mark
+
+ * FS/FS/: cust_bill.pm, part_event/Condition/cust_bill_past_due.pm:
+ invoice past due event, RT#9931
+
+2010-09-17 20:49 ivan
+
+ * FS/FS/cust_main.pm: ignore bad zip on otaker upgrade
+
+2010-09-17 16:32 ivan
+
+ * FS/FS.pm, FS/MANIFEST, FS/FS/Mason.pm, FS/FS/cust_main.pm,
+ FS/FS/ClientAPI/Agent.pm, FS/FS/cust_main/Billing_Realtime.pm,
+ FS/FS/cust_main/Search.pm, httemplate/search/cust_main.cgi,
+ httemplate/search/cust_main.html,
+ rt/lib/RT/URI/freeside/Internal.pm: refactor giant cust_main.pm a
+ little in preparation of adding API methods for maestro, RT#9967
+
+2010-09-17 13:19 ivan
+
+ * FS/: FS.pm, MANIFEST, FS/cust_main.pm, FS/part_pkg.pm,
+ FS/cust_main/Billing.pm, FS/cust_main/Billing_Realtime.pm:
+ refactor giant cust_main.pm a little in preparation of adding API
+ methods for maestro, RT#9967
+
+2010-09-17 12:57 mark
+
+ * FS/FS/: Schema.pm, part_pkg/voip_cdr.pm: cdr.max_callers field
+ and skip option, RT#9810
+
+2010-09-17 11:12 mark
+
+ * httemplate/elements/email-link.html: email_search_result for
+ cust_pkg and svc_broadband, RT#8736
+
+2010-09-17 11:07 mark
+
+ * FS/FS/Mason.pm, FS/FS/cust_main.pm, FS/FS/cust_main_Mixin.pm,
+ FS/FS/svc_broadband.pm, httemplate/elements/menu.html,
+ httemplate/misc/email-customers.html,
+ httemplate/misc/process/email-customers.html,
+ httemplate/search/cust_main.html, httemplate/search/cust_pkg.cgi,
+ httemplate/search/report_svc_broadband.html,
+ httemplate/search/svc_broadband.cgi: email_search_result for
+ cust_pkg and svc_broadband, RT#8736
+
+2010-09-17 10:28 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm: when using src_dst_length_less, add
+ option to charge for CDRs where accountcode is toll free anyway,
+ RT#9683
+
+2010-09-16 22:45 ivan
+
+ * FS/FS/ClientAPI/MyAccount.pm: return svcnum from order_pkg,
+ RT#9906
+
+2010-09-16 09:07 ivan
+
+ * FS/FS/svc_acct.pm: blast from the past: exclamation mark vs.
+ cistron radius with textfiles. RT#9958
+
+2010-09-15 21:17 jeff
+
+ * FS/FS/part_export/domreg_opensrs.pm: default registrations to 1
+ year
+
+2010-09-11 17:47 ivan
+
+ * FS/FS/Maestro.pm: add agent and agentnum to maestro
+ customer_status, RT#9905
+
+2010-09-11 10:27 ivan
+
+ * httemplate/view/svc_domain/dns.html: UI hints for adding new
+ nameservice records, too many to be intuitive now, RT#8933
+
+2010-09-11 10:13 ivan
+
+ * httemplate/docs/credits.html: add Erik to credits
+
+2010-09-11 10:02 ivan
+
+ * FS/FS/Schema.pm, FS/FS/domain_record.pm, FS/FS/svc_domain.pm,
+ FS/FS/part_export/domain_sql.pm,
+ httemplate/view/svc_domain/dns.html: dns updates from Erik L: add
+ ttl support, add check for SRV and finish allowing additional
+ rectypes, allow forward slashes for RFC2317 classless in-arpa
+ delegation, RT#8933
+
+2010-09-10 12:31 ivan
+
+ * FS/FS/part_export/shellcommands.pm: fix shell quoting for
+ agent_custid...
+
+2010-09-09 23:17 ivan
+
+ * rt/lib/RT/Ticket_Overlay.pm: fix for spurious customers
+ appearing, thanks to Erik L
+
+2010-09-08 17:35 mark
+
+ * FS/FS/: cust_pkg.pm, part_pkg.pm, part_pkg/flat.pm: auto-adjourn
+ option in flat packages, RT#9516
+
+2010-09-08 14:33 jeff
+
+ * bin/generate-table-module: restore black magic
+
+2010-09-07 16:31 mark
+
+ * httemplate/search/: report_receivables.html,
+ elements/cust_main_dayranges.html: Option to include customers
+ with credit balances in aging report, RT#9834
+
+2010-09-07 13:25 mark
+
+ * FS/FS/part_export/shellcommands.pm: agent_custid available on
+ replace, RT#9826
+
+2010-09-03 19:19 mark
+
+ * httemplate/: elements/input-date-field.html,
+ misc/cancel_cust.html, misc/cust_main-cancel.cgi,
+ view/cust_main.cgi: set expire date for customer packages,
+ RT#9697
+
+2010-09-03 12:18 ivan
+
+ * rt/FREESIDE_MODIFIED: mandatory RT fields, RT#9260
+
+2010-09-02 15:53 mark
+
+ * FS/FS/part_export/shellcommands.pm: agent_custid in shellcommands
+ export, RT#9826
+
+2010-09-01 16:39 mark
+
+ * rt/: etc/schema.Pg, lib/RT/CustomField.pm,
+ share/html/Admin/CustomFields/Modify.html,
+ share/html/Callbacks/CheckMandatoryFields/Ticket/Elements/Tabs/Default,
+ share/html/Callbacks/CheckMandatoryFields/Ticket/Modify.html/BeforeActionList,
+ share/html/Callbacks/CheckMandatoryFields/Ticket/Update.html/BeforeDisplay,
+ share/html/Ticket/Elements/CheckMandatoryFields: RT mandatory
+ custom fields, RT#9260
+
+2010-09-01 13:25 jeff
+
+ * httemplate/search/477partVI_census.html: warn about multiple
+ states
+
+2010-09-01 11:48 jeff
+
+ * httemplate/search/: 477partIA_detail.html,
+ 477partIA_summary.html: fix xml tag for upper left corner of part
+ ia and actually calculate the above 200kpbs residential
+ percentage
+
+2010-09-01 11:44 mark
+
+ * FS/FS/: msg_template.pm, Cron/notify.pm: packages and recurdates
+ for impending_recur templates
+
+2010-09-01 10:50 mark
+
+ * FS/FS/: Cron/alert_expiration.pm, msg_template.pm: make expdate
+ available in new alerter templates, RT#9786
+
+2010-08-31 10:40 jeff
+
+ * httemplate/search/elements/: cust_pay_or_refund.html,
+ report_cust_pay_or_refund.html: tax names on payment search
+ report #9760
+
+2010-08-27 17:18 mark
+
+ * httemplate/search/: report_cust_bill.html, cust_bill.html: Filter
+ invoice report by payby, RT#9263
+
+2010-08-26 19:10 mark
+
+ * FS/FS/: cust_pkg.pm, part_pkg/flat.pm: per-package option to
+ adjust bill date on unsuspend, RT#8434
+
+2010-08-26 14:10 mark
+
+ * FS/FS/part_pkg/voip_cdr.pm: fix bug affecting single_price
+ calculation
+
+2010-08-25 23:13 ivan
+
+ * rt/share/html/Elements/Header: dashboard subscription fix
+
+2010-08-25 17:08 mark
+
+ * FS/FS/part_pkg/recur_Common.pm: avoid breaking recur_Common
+ dependency
+
+2010-08-25 16:15 ivan
+
+ * bin/select-cust-desync_bill_dates.sql: quick query on desynced
+ bill dates, RT#9733
+
+2010-08-25 15:55 ivan
+
+ * rt/: FREESIDE_MODIFIED, share/html/autohandler: fix fckeditor
+ damage from dashboard fixes: Elements/Footer inadvertantly
+ included in css and javascript, RT#9412
+
+2010-08-25 15:11 ivan
+
+ * FS/FS/part_event/Condition/pkg_next_bill_within.pm: slightly
+ better description?
+
+2010-08-25 15:02 ivan
+
+ * FS/FS/part_event/Condition/: balance_age.pm, once_every.pm:
+ slightly better description?
+
+2010-08-25 11:27 ivan
+
+ * conf/invoice_print_pdf: in default configuration, spool invoices
+ to pdf rather than pipe them to lpr
+
+2010-08-25 02:42 mark
+
+ * FS/: FS/ClientAPI_XMLRPC.pm, FS/ClientAPI/Signup.pm,
+ bin/freeside-selfservice-xmlrpcd: clear signup_info cache when
+ starting xmlrpcd, RT#9380
+
+2010-08-25 02:25 ivan
+
+ * FS/FS/Record.pm: roll back the import transaction on fatal
+ parsing errors on CDR import, so the cdr_batch record gets
+ removed and db doesn't throw a dup key error, RT#9135
+
+2010-08-25 00:34 jeff
+
+ * httemplate/search/: 477partIA_detail.html,
+ elements/search-xml.html: stricter than docs suggest: no 0 values
+ for partIA RT#9721
+
+2010-08-24 16:41 ivan
+
+ * FS/FS/ClientAPI/Signup.pm: typo?
+
+2010-08-24 15:04 ivan
+
+ * FS/bin/freeside-wipe-cvv: blank payinfo instead of "deleted"
+
+2010-08-24 13:14 ivan
+
+ * FS/bin/freeside-prepaidd: insurance against prepaid
+ double-billing, RT#9689
+
+2010-08-24 13:11 ivan
+
+ * FS/FS/ClientAPI/Signup.pm: ensure signup payments are applied,
+ RT#9689
+
+2010-08-24 13:09 ivan
+
+ * FS/bin/freeside-prepaidd: insurance against prepaid
+ double-billing, RT#9689
+
+2010-08-24 12:07 ivan
+
+ * FS/FS/part_pkg/: flat.pm, prorate_Mixin.pm, recur_Common.pm:
+ eliminate needless noise on lack of sync_bill_date option
+
+2010-08-24 11:57 ivan
+
+ * FS/FS/part_export/sqlradius.pm: add debugging and ->finish()
+ before ->disconnect call
+
+2010-08-24 10:30 jeff
+
+ * httemplate/search/: 477.html, 477partIA_detail.html,
+ 477partVI.html, 477partVI_census.html, report_477.html: fixup 477
+ XML output
+
+2010-08-23 20:06 mark
+
+ * FS/bin/freeside-wipe-cvv: script to remove payment info from
+ canceled customers, RT#9652
+
+2010-08-23 20:03 mark
+
+ * FS/FS/pay_batch.pm: delete CVV when processing batch results,
+ RT#9652
+
+2010-08-23 19:27 ivan
+
+ * FS/FS/: cust_pkg.pm, banned_pay.pm, cust_credit.pm, cust_main.pm,
+ cust_pay.pm, cust_pay_void.pm, cust_refund.pm: fix otaker still
+ getting assigned and usernum missing after otaker->usernum
+ upgrade, causes credit report to barf, RT#9712
+
+2010-08-23 19:17 mark
+
+ * FS/FS/pay_batch.pm: premature commit
+
+2010-08-23 18:59 mark
+
+ * FS/FS/: cust_main.pm, pay_batch.pm: delete CVV when processing
+ batch results, RT#9652
+
+2010-08-23 17:55 ivan
+
+ * FS/FS/cust_pay.pm: fix payment receipts when
+ payment_receipt_msgnum is unconfigured
+
+2010-08-23 13:05 jeff
+
+ * httemplate/misc/: cust_main_note-import.cgi,
+ cust_main_note-import.html, process/cust_main_note-import.cgi:
+ support importing customer notes by agent_custid
+
+2010-08-23 13:02 jeff
+
+ * FS/FS/cust_main.pm, httemplate/misc/cust_main-import_charges.cgi,
+ httemplate/misc/process/cust_main-import_charges.cgi: support
+ importing charges by agent_custid
+
+2010-08-23 12:51 jeff
+
+ * FS/FS/cust_main.pm: allow importation of customers with no tax
+ rates
+
+2010-08-23 09:47 jeff
+
+ * FS/FS/: cust_bill.pm, cust_bill_pkg_display.pm: create a default
+ finance section and have hidden sectionless line items remain
+ sectionless
+
+2010-08-23 09:35 jeff
+
+ * FS/FS/Conf.pm,
+ httemplate/misc/xmlhttp-cust_main-censustract.html: work around
+ ffiec bug and add year 2010
+
+2010-08-20 17:17 mark
+
+ * FS/FS/part_event/Condition/: once_every.pm, once_perinv.pm,
+ pkg_next_bill_within.pm: new event conditions, RT#8896
+
+2010-08-19 13:21 mark
+
+ * httemplate/misc/order_pkg.html: fix my mistake
+
+2010-08-19 12:11 mark
+
+ * FS/FS/Conf.pm, FS/FS/part_pkg/flat.pm, FS/FS/part_pkg/prorate.pm,
+ FS/FS/part_pkg/prorate_Mixin.pm, FS/FS/part_pkg/recur_Common.pm,
+ httemplate/misc/order_pkg.html: part_pkg prorate mixin and
+ sync_bill_date option, RT#9554
+
+2010-08-19 04:55 ivan
+
+ * FS/FS/pay_batch.pm: fix batching protection against transactions
+ settled in the meantime, RT#7905
+
+2010-08-19 03:15 ivan
+
+ * FS/FS/: cust_main.pm, part_pkg/flat.pm: fix fixed-amount
+ discounts against packages with pkg add-ons, RT#9669
+
+2010-08-18 16:42 jeff
+
+ * bin/test_scrub: add -h flag to remove history too
+
+2010-08-18 12:20 ivan
+
+ * httemplate/search/rt_transaction.html: fix applied time in time
+ worked report
+
+2010-08-18 11:59 jeff
+
+ * FS/FS/cust_main.pm: still don't want invoices without line items
+
+2010-08-18 10:04 mark
+
+ * httemplate/: elements/menu.html, search/cust_pkg_susp.cgi,
+ search/cust_pkg_susp.html: Suspension/unsuspension report,
+ RT#8464
+
+2010-08-17 20:43 jeff
+
+ * FS/FS/cust_bill.pm: handle the usage_class-less details in
+ svc_phone sections
+
+2010-08-17 18:33 jeff
+
+ * FS/FS/: cust_main.pm, cust_bill.pm: allow sections to work
+ without 'use_separation,' correct packages hidden behind zero
+ value packages, correct section handling, and fix propogation of
+ other display attributes to child packages
+
+2010-08-17 17:14 ivan
+
+ * htetc/freeside-rt.conf, rt/FREESIDE_MODIFIED,
+ rt/share/html/Elements/Dashboards: fix directory links in RT (not
+ picking up index.html as a default), RT#9665, fallout from
+ RT#9412
+
+2010-08-17 10:08 ivan
+
+ * FS/FS/Conf.pm, FS/FS/domain_record.pm, FS/FS/svc_domain.pm,
+ httemplate/edit/process/domain_record.cgi,
+ httemplate/edit/process/svc_domain-defaultrecords.cgi,
+ httemplate/elements/freeside.css,
+ httemplate/view/svc_domain/dns.html: DNS, RT#8933
+
+2010-08-17 10:05 ivan
+
+ * FS/MANIFEST: communigate phase 3: certificates, RT#7515
+
+2010-08-17 00:07 ivan
+
+ * httemplate/edit/: msg_template.html: better sizes and labels for
+ message tempalte subject and addresses
+
+2010-08-16 23:57 ivan
+
+ * httemplate/: browse/msg_template.html, edit/msg_template.html:
+ allow Configuration right to see global message templates, avoid
+ weird surprises on upgrade
+
+2010-08-16 23:45 ivan
+
+ * etc/sql-reserved-words.txt: mysql reserves all sorts of things
+
+2010-08-16 23:41 ivan
+
+ * FS/FS/Schema.pm, FS/FS/acct_snarf.pm,
+ httemplate/edit/acct_snarf.html: LEAVE is reserved in msyql
+
+2010-08-16 18:05 mark
+
+ * fs_selfservice/FS-SelfService/: SelfService.pm, cgi/signup.html:
+ fix SelfService county selector, RT#8079
+
+2010-08-16 13:24 ivan
+
+ * httemplate/view/svc_phone.cgi: fix search of pending/billed CDRs
+ to find src field too, RT#9640
+
+2010-08-16 13:11 ivan
+
+ * FS/: FS/Upgrade.pm, bin/freeside-upgrade,
+ FS/cust_bill_pkg_detail.pm: fix upgrade with ancient
+ cust_bill_pkg_detail.classnum but new DBIx::DBSchema, RT#9640
+
+2010-08-16 12:45 ivan
+
+ * httemplate/: search/cdr.html, view/svc_phone.cgi: fix search of
+ pending/billed CDRs to find src field too, RT#9640
+
+2010-08-16 10:49 mark
+
+ * FS/FS/Misc.pm, FS/FS/Schema.pm, FS/FS/msg_template.pm,
+ httemplate/edit/msg_template.html: Bcc address for impending
+ recur notices, RT#8953
+
+2010-08-15 00:00 ivan
+
+ * httemplate/: elements/select-user.html,
+ search/cust_bill_pkg_discount.html, search/cust_credit.html,
+ search/cust_pkg_discount.html,
+ search/report_cust_bill_pkg_discount.html,
+ search/report_cust_credit.html,
+ search/report_cust_pkg_discount.html,
+ search/report_h_cust_pay.html: additional by-otaker searches
+ fixed for the brave new world of usernum, RT#9555
+
+2010-08-14 23:21 ivan
+
+ * httemplate/: elements/select-user.html, graph/money_time.cgi,
+ misc/process/batch-cust_pay.cgi,
+ misc/process/cust_pay-import.cgi, search/cust_pay.cgi,
+ search/cust_pay.html, search/report_cust_pay.html,
+ search/report_cust_refund.html,
+ search/elements/cust_pay_or_refund.html,
+ search/elements/report_cust_pay_or_refund.html: fix payment and
+ refund searches by otaker (now usernum), RT#9555
+
+2010-08-14 18:32 ivan
+
+ * rt/: FREESIDE_MODIFIED, share/html/Elements/Dashboards: fix
+ Dashboards edit link too, RT#9412
+
+2010-08-14 18:19 ivan
+
+ * htetc/freeside-rt.conf: fix RT dashboards and other things that
+ need a Mason dhandler/autohandler: Approvals, Admin,
+ Ticket/AttachmentWithHeaders, RT#9412
+
+2010-08-14 17:44 ivan
+
+ * FS/FS/Mason.pm, FS/FS/Mason/Request.pm, htetc/freeside-rt.conf,
+ htetc/handler.pl, rt/share/html/Elements/ColumnMap,
+ rt/share/html/Elements/RefreshHomepage, rt/FREESIDE_MODIFIED,
+ rt/share/html/Admin/Elements/EditCustomFields,
+ rt/share/html/Elements/RT__CustomField/ColumnMap,
+ rt/share/html/Ticket/Graphs/index.html: address root cause of
+ rt/rt links and remove the workarounds, RT#9280
+
+2010-08-13 16:53 ivan
+
+ * httemplate/elements/: city.html: fix city blanking on county
+ change, RT#9627
+
+2010-08-13 12:53 ivan
+
+ * FS/FS/cust_main.pm: slightly better customer delete; remove links
+ to tickets, RT#9626
+
+2010-08-13 12:26 ivan
+
+ * FS/FS/Conf.pm, httemplate/view/cust_main.cgi: add
+ cust_main-title-display_custnum, RT#9621
+
+2010-08-13 10:41 ivan
+
+ * FS/FS/cust_main/Import.pm: ignore expired cards on customer
+ import
+
+2010-08-12 22:55 jeff
+
+ * FS/FS/Record.pm: tyop
+
+2010-08-12 22:51 jeff
+
+ * FS/FS/Record.pm: make ut_textn analogous to ut_text
+
+2010-08-12 15:25 ivan
+
+ * httemplate/edit/process/svc_acct.cgi: counter values can be
+ negative
+
+2010-08-12 14:31 mark
+
+ * FS/FS/ClientAPI_XMLRPC.pm, FS/FS/ClientAPI/Signup.pm,
+ fs_selfservice/drupal/admin.inc,
+ fs_selfservice/drupal/freeside.class.php,
+ fs_selfservice/drupal/freeside.info,
+ fs_selfservice/drupal/freeside.module,
+ fs_selfservice/drupal/signup.inc: self-service Drupal module,
+ RT#9380
+
+2010-08-12 10:43 ivan
+
+ * FS/FS/Conf.pm: referraldefault dropdown in config, RT#9599
+
+2010-08-12 10:36 ivan
+
+ * FS/FS/cust_main.pm: cust_recon throws errors and it is not a
+ normally used table anyway
+
+2010-08-11 14:53 ivan
+
+ * FS/FS/cust_main/Import.pm: at least show an error for bad
+ pkgparts instead of a hang, RT#9578
+
+2010-08-10 23:35 ivan
+
+ * FS/FS/cust_main.pm, bin/wipe-customers,
+ httemplate/misc/process/delete-customer.cgi: a better customer
+ delete, RT#9564
+
+2010-08-10 20:49 ivan
+
+ * httemplate/view/cust_main/payment_history.html: valign=top
+
+2010-08-10 20:48 ivan
+
+ * httemplate/elements/table-grid.html: less visual noise
+
+2010-08-10 17:42 ivan
+
+ * httemplate/edit/REAL_cust_pkg.cgi: fix date editing
+ w/international dates, RT#9509
+
+2010-08-10 17:08 ivan
+
+ * FS/FS/cust_main.pm: fix return address in welcome letters,
+ RT#9497
+
+2010-08-10 14:37 ivan
+
+ * httemplate/elements/xmlhttp.html: eliminate the '0 status
+ connecting' errors, they're not telling us anything and causing
+ lots of people to waste time asking
+
+2010-08-09 23:28 ivan
+
+ * FS/FS/cust_main.pm, FS/FS/cust_bill.pm, conf/welcome_letter: add
+ logo_file support to welcome_letter and fix leaving temp files
+ around for invoices and letters, RT#9497
+
+2010-08-09 13:30 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm: fix harmless cdr_svc_method noise,
+ RT#9428
+
+2010-08-09 12:20 ivan
+
+ * FS/FS/cust_pkg/Import.pm, httemplate/misc/cust_pkg-import.html:
+ package web import from CSV/XLS, RT#9529
+
+2010-08-09 10:22 ivan
+
+ * FS/FS/access_user.pm: return username as a name lable for
+ "Lastname, Firstname" employees
+
+2010-08-09 09:20 ivan
+
+ * FS/FS/cust_main.pm: fix cancellation error "No schema for table
+ table found", seems to be fallout from cust_tag work, RT#9502
+
+2010-08-09 08:46 ivan
+
+ * httemplate/misc/cust_pkg-import.html: fix label, RT#9529
+
+2010-08-08 18:03 ivan
+
+ * httemplate/elements/menu.html, Makefile, FS/FS/Mason.pm,
+ FS/FS/Record.pm, FS/FS/Schema.pm, FS/FS/cust_pkg.pm,
+ FS/FS/cust_pkg/Import.pm, FS/bin/freeside-queued,
+ httemplate/misc/cust_main-import.cgi,
+ httemplate/misc/cust_pkg-import.html,
+ httemplate/misc/process/cust_pkg-import.html,
+ httemplate/search/cust_pkg.cgi: package web import from CSV/XLS,
+ RT#9529
+
+2010-08-07 03:11 ivan
+
+ * FS/FS/: acct_snarf.pm, svc_acct.pm,
+ part_export/communigate_pro.pm: communigate phase 3:
+ RPOP/acct_snarf, RT#7515
+
+2010-08-07 00:39 ivan
+
+ * FS/FS/Mason.pm, FS/FS/Schema.pm, FS/FS/acct_snarf.pm,
+ httemplate/browse/acct_snarf.html,
+ httemplate/edit/acct_snarf.html,
+ httemplate/edit/process/acct_snarf.html,
+ httemplate/view/svc_acct/communigate.html: communigate phase 3:
+ RPOP/acct_snarf, RT#7515
+
+2010-08-07 00:39 ivan
+
+ * httemplate/elements/freeside.css: style password entry 2.1 style
+ too
+
+2010-08-06 21:10 ivan
+
+ * bin/: 19add, 19commit, 19diff: these are useful
+
+2010-08-06 14:31 ivan
+
+ * httemplate/edit/part_svc.cgi, FS/FS/svc_acct.pm: communigate
+ phase 3: archive messages, RT#7515
+
+2010-08-06 14:28 ivan
+
+ * FS/FS/Schema.pm, FS/FS/svc_acct.pm, FS/FS/svc_domain.pm,
+ httemplate/edit/part_svc.cgi, httemplate/edit/svc_acct.cgi,
+ httemplate/edit/svc_domain.cgi,
+ httemplate/edit/svc_acct/communigate.html,
+ FS/FS/part_export/communigate_pro.pm,
+ httemplate/edit/svc_domain/communigate-acct_defaults.html,
+ httemplate/edit/svc_domain/communigate-basics.html,
+ httemplate/view/svc_acct/communigate.html,
+ httemplate/view/svc_domain/acct_defaults.html: communigate phase
+ 3: archive messages, RT#7515
+
+2010-08-05 17:45 ivan
+
+ * httemplate/: edit/cgp_rule-redirect_all.html,
+ edit/cgp_rule-vacation.html,
+ edit/process/cgp_rule-redirect_all.html,
+ edit/process/cgp_rule-simplified.html,
+ edit/process/cgp_rule-vacation.html,
+ view/svc_acct/communigate.html: communigate account rules:
+ vacation & redirect all, RT#7514
+
+2010-08-05 13:10 mark
+
+ * httemplate/: elements/menu.html, search/cust_pkg_summary.cgi,
+ search/cust_pkg_summary.html: Package summary report, RT#8461
+
+2010-08-04 21:17 jeff
+
+ * FS/FS/: cust_svc.pm, part_export/sqlradius.pm: add options to
+ only process account records from a particular realm and to
+ ignore sessions that span billing periods RT8082
+
+2010-08-04 17:24 mark
+
+ * FS/FS/cust_main.pm, FS/FS/msg_template.pm,
+ httemplate/edit/msg_template.html: error message in decline
+ templates, RT#9507
+
+2010-08-04 12:14 ivan
+
+ * FS/FS/Record.pm, FS/FS/cust_main.pm,
+ httemplate/view/cust_main/payment_history.html,
+ httemplate/view/cust_main/payment_history/attempted_payment.html:
+ show cust_pay_pending attempted payments on customer payment
+ history, RT#8815
+
+2010-08-04 11:50 ivan
+
+ * FS/FS/Record.pm: fix scalar_sql not to return empty string for
+ zero
+
+2010-08-04 02:34 ivan
+
+ * rt/FREESIDE_MODIFIED: fix additional instance of rt/rt problem,
+ RT#9280
+
+2010-08-04 02:25 ivan
+
+ * rt/share/html/Admin/Elements/EditCustomFields: fix additional
+ instance of rt/rt problem, RT#9280
+
+2010-08-03 18:30 ivan
+
+ * FS/FS/part_export/communigate_pro.pm: better serialization on
+ debugging data, RT#7514
+
+2010-08-03 18:26 ivan
+
+ * FS/FS/part_export/: communigate_pro.pm: better serialization on
+ debugging data, RT#7514
+
+2010-08-03 18:22 ivan
+
+ * FS/FS/part_export/: communigate_pro.pm: better serialization on
+ debugging data, RT#7514
+
+2010-08-03 18:15 ivan
+
+ * FS/FS/part_export/communigate_pro.pm: better serialization on
+ debugging data, RT#7514
+
+2010-08-03 16:20 ivan
+
+ * httemplate/elements/header.html: margin and padding css defined
+ properly in px
+
+2010-08-03 16:12 ivan
+
+ * httemplate/browse/cgp_rule.html: don't allow addition of a domain
+ rule template to itself, RT#7514
+
+2010-08-03 11:07 ivan
+
+ * conf/svc_acct-disable_access_number: default config turns off
+ svc_acct access number selectios
+
+2010-08-03 11:00 ivan
+
+ * httemplate/search/cust_main.cgi: spelling
+
+2010-08-02 23:31 ivan
+
+ * FS/FS/part_pkg/flat.pm: fix problem with expiring discounts,
+ RT#6679
+
+2010-08-02 20:30 mark
+
+ * FS/FS/Conf.pm, FS/FS/Mason.pm,
+ httemplate/misc/custom_link_proxy.cgi,
+ httemplate/view/cust_main.cgi,
+ httemplate/view/cust_main/custom.html: customer view tab for an
+ external info page, RT#8903
+
+2010-08-02 19:57 ivan
+
+ * httemplate/misc/: timeworked.html: cleaner timeworked results
+ w/link to customer
+
+2010-08-02 19:11 ivan
+
+ * FS/FS/cust_main.pm: fix active customers sometimes showing in
+ search results for new "ordered" status, RT#9381
+
+2010-07-30 15:26 mark
+
+ * FS/FS/msg_template.pm: fix warning
+
+2010-07-30 15:08 mark
+
+ * FS/FS/Conf.pm, FS/FS/cust_pay.pm, FS/FS/msg_template.pm,
+ httemplate/edit/msg_template.html: payment receipts use
+ msg_template, RT#9060
+
+2010-07-29 23:24 mark
+
+ * FS/FS/Cron/notify.pm: fix typo
+
+2010-07-29 17:13 jeff
+
+ * FS/FS/tax_rate.pm: fix error message to be more useful
+
+2010-07-29 16:11 mark
+
+ * FS/FS/: Misc.pm, part_pkg.pm, part_event/Condition/pkg_freq.pm:
+ add pkg_freq event condition, RT#8896
+
+2010-07-29 09:41 mark
+
+ * httemplate/search/elements/cust_main_dayranges.html: aging report
+ now uses DateTime, RT#9417
+
+2010-07-28 20:44 ivan
+
+ * rt/share/html/Ticket/Checklist.html: start of checklist/workflow,
+ RT#8805
+
+2010-07-28 20:41 ivan
+
+ * rt/FREESIDE_MODIFIED, rt/share/html/Elements/ShowLink_Checklist,
+ rt/share/html/Ticket/Checklist.html,
+ rt/share/html/Ticket/Elements/ShowMembers_Checklist,
+ rt/share/html/Ticket/Elements/Tabs, httemplate/images/square.png,
+ httemplate/images/square_add.png: start of checklist/workflow,
+ RT#8805
+
+2010-07-28 16:16 mark
+
+ * FS/FS/Conf.pm, FS/FS/Schema.pm, FS/FS/Upgrade.pm,
+ FS/FS/cust_main.pm, FS/FS/cust_pkg.pm, FS/FS/msg_template.pm,
+ FS/FS/svc_acct.pm, FS/FS/Cron/alert_expiration.pm,
+ FS/FS/Cron/notify.pm, httemplate/config/config-view.cgi,
+ httemplate/edit/msg_template.html: msg_template improvements,
+ RT#8324
+
+2010-07-28 12:32 ivan
+
+ * rt/lib/RT/Ticket_Overlay.pm: don't fire scrips for reminders
+ either, RT#8260
+
+2010-07-27 19:51 ivan
+
+ * FS/FS/: Upgrade.pm: fix unnecessary warnings on upgrade of remote
+ Pg RADIUS db, RT#9178
+
+2010-07-27 19:46 ivan
+
+ * FS/FS/Upgrade.pm: fix unnecessary warnings on upgrade of remote
+ Pg RADIUS db, RT#9178
+
+2010-07-27 15:34 ivan
+
+ * rt/share/html/Elements/EditCustomFieldDate: no times on custom
+ field dates, at least for now, RT#8449
+
+2010-07-27 15:18 ivan
+
+ * bin/rt-update-customfield-dates: correct custom field dates,
+ RT#8449
+
+2010-07-27 03:09 ivan
+
+ * rt/lib/RT/Ticket_Overlay.pm: don't leak transactions indicating
+ reminders are implemented as tickets, RT#8260
+
+2010-07-27 02:02 ivan
+
+ * rt/lib/RT/Ticket_Overlay.pm: should fix customer #1 getting added
+ if i set myself as a requestor on ticket creation, RT#6640
+
+2010-07-27 00:08 ivan
+
+ * FS/FS/Maestro.pm: fix fallout with original API usage, oops,
+ RT#9334
+
+2010-07-26 23:51 ivan
+
+ * httemplate/misc/maestro-customer_status.cgi: extend maestro
+ status API for the multi-service scenario, RT#9334
+
+2010-07-26 23:48 ivan
+
+ * FS/FS/Maestro.pm, FS/FS/cust_pkg.pm,
+ httemplate/misc/maestro-customer_status-test.html,
+ httemplate/misc/maestro-customer_status.cgi,
+ httemplate/misc/maestro-customer_status.html,
+ httemplate/view/cust_main/packages.html,
+ httemplate/view/cust_main/packages/services.html: extend maestro
+ status API for the multi-service scenario, RT#9334
+
+2010-07-26 18:57 ivan
+
+ * httemplate/misc/: maestro-customer_status-test.html: better
+ pretty-printing for array values
+
+2010-07-26 18:48 ivan
+
+ * FS/FS/Conf.pm, httemplate/misc/maestro-customer_status-test.html,
+ httemplate/view/cust_main.cgi: add test page for maestro status,
+ RT#9381
+
+2010-07-26 16:00 ivan
+
+ * httemplate/elements/header-popup.html: add doc
+
+2010-07-26 15:59 ivan
+
+ * httemplate/: view/svc_acct/communigate.html,
+ edit/cgp_rule-redirect_all.html, edit/cgp_rule-vacation.html:
+ communigate vacation & redirect all rules, RT#7514
+
+2010-07-25 22:01 ivan
+
+ * rt/: FREESIDE_MODIFIED, share/html/Ticket/Elements/BulkLinks: fix
+ needless error when bulk deleting tickets
+
+2010-07-25 13:44 jeff
+
+ * FS/FS/Upgrade.pm: missing upgrade
+
+2010-07-25 00:30 ivan
+
+ * FS/FS/otaker_Mixin.pm: limit memory use when upgrading
+ attachments
+
+2010-07-25 00:08 ivan
+
+ * FS/FS/cust_pay.pm: proceed with upgrade even when N/A cards can't
+ be recovered
+
+2010-07-25 00:03 ivan
+
+ * FS/FS/cust_main.pm: don't queue fuzzyfile upgrade jobs on otaker
+ upgrade
+
+2010-07-23 16:16 ivan
+
+ * FS/FS/pay_batch.pm: put batch card numbers/masks in cust_pay so
+ they can be refunded, patch from peter loeppky, RT#8776
+
+2010-07-23 15:50 ivan
+
+ * rt/lib/RT/Tickets_Overlay.pm: cleaner customer number searching,
+ RT#8784
+
+2010-07-23 15:09 ivan
+
+ * rt/: share/html/Elements/RT__CustomField/ColumnMap,
+ FREESIDE_MODIFIED, share/html/Elements/RefreshHomepage: fix rt/rt
+ links moving custom fields up/down and refreshing homepage,
+ RT#9280
+
+2010-07-23 03:02 ivan
+
+ * FS/FS/svc_CGP_Mixin.pm, FS/FS/svc_acct.pm, FS/FS/svc_domain.pm,
+ httemplate/edit/svc_acct.cgi, httemplate/edit/svc_domain.cgi: add
+ EmptyTrash values and finish consolidating the CGP timezone
+ arrays, RT#7083
+
+2010-07-23 02:32 ivan
+
+ * FS/MANIFEST, FS/FS/svc_CGPRule_Mixin.pm, FS/FS/svc_CGP_Mixin.pm,
+ FS/FS/svc_acct.pm, FS/FS/svc_domain.pm,
+ httemplate/edit/svc_acct.cgi, httemplate/edit/svc_domain.cgi:
+ consolidate four CGP timezone arrays, RT#7083
+
+2010-07-22 17:11 ivan
+
+ * FS/FS/access_user.pm, httemplate/edit/cust_main/top_misc.html,
+ httemplate/elements/tr-select-agent.html,
+ httemplate/misc/inventory_item-import.html: 'View customers of
+ all agents' doesn't mean create them, or upload inventory,
+ RT#7010
+
+2010-07-22 16:33 mark
+
+ * FS/FS/Conf.pm, httemplate/view/cust_main/tickets.html: option to
+ force default queue for new tickets in cust_main, RT#8889
+
+2010-07-22 14:01 ivan
+
+ * rt/: lib/RT/Tickets_Overlay.pm,
+ share/html/Search/Elements/DisplayOptions: sorting ticket results
+ by customer custnum or name, RT#8784
+
+2010-07-22 12:42 mark
+
+ * httemplate/edit/elements/rate_detail.html: cdr rating by day and
+ time, part 2, RT#4763
+
+2010-07-22 12:42 ivan
+
+ * rt/lib/RT/URI/freeside.pm: eliminate needless backtraces
+
+2010-07-22 09:47 ivan
+
+ * FS/FS/msg_template.pm, httemplate/browse/msg_template.html,
+ httemplate/edit/msg_template.html,
+ httemplate/edit/process/msg_template.html,
+ httemplate/elements/menu.html: allow Configuration ACL to edit
+ templates, RT#8324
+
+2010-07-22 00:25 mark
+
+ * httemplate/search/: report_receivables.cgi,
+ unapplied_cust_pay.html, elements/cust_main_dayranges.html: Fix
+ weird behavior of aging report, RT#9234
+
+2010-07-21 17:11 mark
+
+ * FS/FS/rate_time_interval.pm, httemplate/browse/rate_detail.html,
+ httemplate/edit/rate.cgi, httemplate/edit/rate_region.cgi,
+ httemplate/edit/rate_time.cgi,
+ httemplate/edit/process/rate_region.cgi,
+ httemplate/edit/process/rate_time.cgi,
+ httemplate/elements/auto-table.html,
+ httemplate/elements/menu.html: cdr rating by day and time, part
+ 2, RT#4763
+
+2010-07-21 14:07 ivan
+
+ * rt/etc/: RT_Config.pm, RT_Config.pm.in: RTx::Checklist still in
+ dev
+
+2010-07-21 03:31 ivan
+
+ * rt/: FREESIDE_MODIFIED, etc/RT_Config.pm, etc/RT_Config.pm.in,
+ share/html/Elements/RT__Ticket/ColumnMap,
+ share/html/Search/Elements/BuildFormatString: show customers in
+ ticket lists, RT#8784
+
+2010-07-20 19:10 ivan
+
+ * rt/: FREESIDE_MODIFIED, share/html/Search/Build.html,
+ share/html/Search/Elements/PickCFs: fix problems searching the
+ new custom fields w/dates, RT#8449
+
+2010-07-20 19:06 ivan
+
+ * FS/FS/Mason.pm, rt/share/html/Elements/SelectDate: fix calendar
+ popup for weirdly named fields in RT, for the quotes around
+ custom field bullshit, RT#8449
+
+2010-07-20 10:23 ivan
+
+ * httemplate/docs/about.html: 2.1.1
+
+2010-07-19 17:59 ivan
+
+ * rt/: lib/RT/CustomField_Overlay.pm, lib/RT/Record.pm,
+ lib/RT/Tickets_Overlay.pm, lib/RT/Interface/Web.pm,
+ FREESIDE_MODIFIED, share/html/Elements/EditCustomFieldDate,
+ share/html/Elements/ShowCustomFieldDate,
+ share/html/Search/Build.html, share/html/Search/Elements/PickCFs:
+ RT custom fields patch, RT#8449
+
+2010-07-17 15:26 ivan
+
+ * httemplate/edit/part_tag.html: customer tags, RT#9192
+
+2010-07-17 15:14 ivan
+
+ * FS/FS/UI/Web/small_custview.pm, httemplate/view/cust_main.cgi,
+ httemplate/view/cust_main/misc.html, FS/FS/Conf.pm: customer
+ tags, RT#9192
+
+2010-07-17 14:41 ivan
+
+ * httemplate/: browse/part_tag.html, edit/part_tag.html,
+ elements/pickcolor.html, elements/tr-pickcolor.html: customer
+ tags, RT#9192
+
+2010-07-16 16:45 ivan
+
+ * FS/FS/AccessRight.pm, FS/FS/Record.pm, FS/FS/cust_main.pm,
+ FS/FS/cust_tag.pm, httemplate/elements/select-cust_tag.html,
+ httemplate/elements/tr-select-cust_tag.html,
+ httemplate/edit/part_tag.html,
+ httemplate/edit/cust_main/top_misc.html,
+ httemplate/edit/process/cust_main.cgi,
+ httemplate/view/cust_main/misc.html: customer tags, RT#9192
+
+2010-07-15 20:09 mark
+
+ * httemplate/view/cust_main/tickets.html: adjust "Create Ticket"
+ link, RT#7656
+
+2010-07-15 14:46 ivan
+
+ * FS/FS.pm, FS/MANIFEST, FS/FS/Schema.pm, FS/FS/Mason.pm,
+ FS/FS/cust_tag.pm, FS/FS/part_tag.pm, FS/t/cust_tag.t,
+ FS/t/part_tag.t, httemplate/browse/part_tag.html,
+ httemplate/edit/part_tag.html,
+ httemplate/edit/process/part_tag.html,
+ httemplate/elements/menu.html: customer tags, RT#9192
+
+2010-07-15 13:34 mark
+
+ * FS/FS/part_pkg/voip_cdr.pm: add skip_dst_prefix option, RT#3288
+
+2010-07-13 17:19 jeff
+
+ * FS/FS/part_pkg/voip_cdr.pm: include rate_detail->conn_sec in
+ displayed duration #RT8605
+
+2010-07-13 16:11 mark
+
+ * FS/FS/: Misc.pm, cust_main.pm: improve error handling on mass
+ email jobs, RT#8720
+
+2010-07-13 15:55 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm, httemplate/edit/part_pkg.cgi:
+ Optional alternate rate plan when accountcode is toll free,
+ RT#8084
+
+2010-07-13 04:09 ivan
+
+ * FS/FS/Mason.pm, FS/FS/Schema.pm, FS/FS/msg_template.pm,
+ httemplate/edit/msg_template.html,
+ httemplate/edit/elements/edit.html,
+ httemplate/elements/htmlarea.html, FS/FS/cust_main.pm,
+ FS/FS/part_event/Action/notice.pm: notices, RT#8324
+
+2010-07-12 15:55 mark
+
+ * httemplate/: elements/bill.html, view/cust_main/billing.html: fix
+ "Bill now" link, RT#9207
+
+2010-07-12 06:17 ivan
+
+ * FS/FS/AccessRight.pm, FS/FS/Schema.pm, FS/FS/msg_template.pm,
+ FS/FS.pm, FS/MANIFEST, FS/t/msg_template.t,
+ httemplate/browse/msg_template.html,
+ httemplate/edit/msg_template.html,
+ httemplate/edit/process/msg_template.html,
+ httemplate/elements/menu.html,
+ httemplate/elements/tr-htmlarea.html: message templates, RT#8896
+
+2010-07-12 06:07 ivan
+
+ * FS/FS/Mason.pm: message templates, RT#8896
+
+2010-07-11 23:19 ivan
+
+ * httemplate/view/svc_domain/dns.html: fix wording
+
+2010-07-10 02:43 ivan
+
+ * FS/FS/cust_credit_bill_pkg.pm: should fix intermittent "Illegal
+ (money) amount" error applying credits to invoices when using
+ texas tax, RT#8930
+
+2010-07-10 02:17 ivan
+
+ * httemplate/: elements/bill.html, view/cust_main/billing.html:
+ prevent new "Bill now" link from futzing up later forms, RT#9193
+
+2010-07-09 23:31 ivan
+
+ * FS/FS/cdr.pm: more resilliant cdrbatch upgrade
+
+2010-07-09 23:23 ivan
+
+ * FS/FS/cust_pay.pm: fix payinfo N/A upgrade, RT#8809
+
+2010-07-09 19:15 mark
+
+ * FS/FS/part_export/ldap.pm: LDAP export delete and replace
+ methods, RT#1854
+
+2010-07-09 15:34 ivan
+
+ * rpm/build/: BOOTSTRAP, enrpm, native/ovid2flute: changes to get
+ enrpm working
+
+2010-07-08 16:53 ivan
+
+ * rpm/build/: build-freeside, expect-addsign, BOOTSTRAP: 32 bit and
+ make the repo stuff work, RT#8190
+
+2010-07-08 16:08 ivan
+
+ * rpm/build/: BOOTSTRAP, build-freeside, buildsysrc,
+ mock/centos-5-i386.cfg: 32 bit and make the repo stuff work,
+ RT#8190
+
+2010-07-08 14:28 ivan
+
+ * rpm/build/: build-freeside, cvs-check-and-build, refresh-repo,
+ mock/centos-5-i386.cfg, mock/centos-5-x86_64.cfg: generating
+ RPMS, RT#8190
+
+2010-07-08 02:32 ivan
+
+ * rpm/build/: BOOTSTRAP, build-freeside, buildsysrc,
+ cvs-check-and-build, mock/centos-5-i386.cfg,
+ mock/centos-5-x86_64.cfg, mock/defaults.cfg, mock/logging.ini,
+ mock/site-defaults.cfg, mock/sles-10-i386.cfg,
+ mock/sles-10-x86_64.cfg, native/Ovid.diff, native/build-from-cvs,
+ native/freeside-cvs, native/makesrpm,
+ native/ovid-0.12-1.x86_64.rpm, native/ovid2flute: checking in
+ more of the rpm build system (is that it?)
+
+2010-07-07 18:15 jeff
+
+ * FS/FS/tax_rate.pm: grr - fix agentnum passing
+
+2010-07-07 14:00 ivan
+
+ * rpm/build/build-freeside: dropping centos/rhel 4
+
+2010-07-07 10:51 ivan
+
+ * rpm/build/: build-freeside, cvs-check-and-build, enrpm,
+ expect-addsign, expect-signrepo, ovid2flute, refresh-repo:
+ checking in RPM build system
+
+2010-07-06 13:59 mark
+
+ * FS/bin/freeside-queued: fix oops
+
+2010-07-06 13:56 mark
+
+ * FS/bin/: freeside-cdr-sftp_and_import, freeside-queued: add
+ command line opts for port, passive mode, and debug level,
+ RT#9115
+
+2010-07-06 05:18 mark
+
+ * FS/FS/cust_main.pm, httemplate/elements/bill.html,
+ httemplate/elements/progress-init.html, httemplate/misc/bill.cgi,
+ httemplate/view/cust_main/billing.html: "Bill now" link uses job
+ queue/progressbar, RT#8995
+
+2010-07-05 14:10 ivan
+
+ * FS/FS/part_export/: cust_http.pm, http.pm: customer exports,
+ RT#8952
+
+2010-07-05 13:18 jeff
+
+ * FS/FS/part_export/domreg_opensrs.pm: place errors where users can
+ find them (in the queue)
+
+2010-07-05 01:59 jeff
+
+ * FS/FS/part_export/domreg_opensrs.pm: fix bad bug causing
+ inappropriate renewals
+
+2010-07-02 21:19 ivan
+
+ * FS/bin/freeside-selfservice-xmlrpcd: fix leaking db connections
+ in freeside-selfservice-xmlrpcd, RT#7780
+
+2010-07-02 18:25 ivan
+
+ * FS/FS/cust_main.pm: should fix cancellations in rare
+ circumstances where cached _num_cust_svc becomes inaccurate,
+ RT#8994
+
+2010-07-02 16:36 mark
+
+ * FS/FS/part_pkg/voip_cdr.pm: fix bad bug from #4763
+
+2010-07-02 11:56 ivan
+
+ * FS/FS/cust_main.pm: more debugging for weird bill lockup, RT#8993
+
+2010-07-01 20:06 jeff
+
+ * conf/invoice_html: fix html ext_desc alignment in svc_phone
+ sections
+
+2010-07-01 17:25 ivan
+
+ * FS/FS/: cust_event.pm, cust_main.pm: fix bad transactional
+ decisions that made it possible to abort and rollback a gateway
+ payment, RT#8995
+
+2010-07-01 12:30 ivan
+
+ * httemplate/docs/credits.html: He's gone
+
+2010-06-30 18:53 mark
+
+ * FS/FS/Mason.pm, FS/FS/Schema.pm, FS/FS/rate.pm,
+ FS/FS/rate_detail.pm, FS/FS/rate_time.pm,
+ FS/FS/rate_time_interval.pm, FS/FS/part_pkg/voip_cdr.pm,
+ FS/t/rate_time.t, FS/t/rate_time_interval.t,
+ httemplate/browse/rate.cgi, httemplate/browse/rate_detail.html,
+ httemplate/browse/rate_time.html,
+ httemplate/edit/rate_detail.html, httemplate/edit/rate_time.cgi,
+ httemplate/edit/process/rate_time.cgi,
+ httemplate/elements/auto-table.html,
+ httemplate/misc/delete-rate_detail.html: voip_cdr call rating by
+ day and time, RT#4763
+
+2010-06-30 14:56 ivan
+
+ * FS/bin/: freeside-dbdef-create, freeside-fetch, freeside-setup:
+ -T causing problems
+
+2010-06-30 13:42 ivan
+
+ * FS/FS/Conf.pm, FS/FS/cust_main.pm,
+ FS/FS/part_export/cust_http.pm, FS/FS/part_export/http.pm,
+ httemplate/config/config.cgi,
+ httemplate/config/config-process.cgi: cust_main exports!
+ cust_main-exports config option and part_export/cust_http.pm
+ export, RT#8952
+
+2010-06-30 10:48 ivan
+
+ * rt/share/html/Elements/ShowUserVerbose: better looking verbose
+ user strings with less visual noise
+
+2010-06-30 00:09 ivan
+
+ * FS/FS/Mason.pm: final fix for ticket links graph reliability:
+ make sure RT::Util saft_run-child is always available
+
+2010-06-29 23:58 ivan
+
+ * htetc/freeside-rt.conf: one more fix for RT links graphs
+
+2010-06-29 23:52 ivan
+
+ * rt/share/html/Ticket/Graphs/index.html: fix another case of
+ rt/rt/, urg
+
+2010-06-29 23:47 ivan
+
+ * FS/FS/Mason.pm: depend on IPC::Run::SafeHandles rather than
+ barfing an error on ticket links graph
+
+2010-06-29 12:51 ivan
+
+ * FS/FS/: Record.pm, cdr.pm, cdr/taqua_om.pm: Taqua OM CDR format,
+ RT#7518
+
+2010-06-28 22:22 ivan
+
+ * httemplate/: graph/cust_bill_pkg.cgi, search/cust_bill_pkg.cgi,
+ search/cust_credit_bill_pkg.html,
+ search/report_prepaid_income.cgi: eliminate filtering of info
+ from COMP customers on financial reports, RT#8787
+
+2010-06-28 21:32 ivan
+
+ * FS/FS/cust_main.pm: Ordered status for the limbo between Prospect
+ and Active, RT#8712
+
+2010-06-28 21:22 ivan
+
+ * FS/FS/: cust_main.pm, cust_pkg.pm: Ordered status for the limbo
+ between Prospect and Active, RT#8712
+
+2010-06-28 18:40 ivan
+
+ * FS/FS/: svc_pbx.pm, Conf.pm: add global_unique-pbx_title to
+ disable duplicate checking on svc_pbx.title
+
+2010-06-28 18:17 jeff
+
+ * httemplate/misc/process/recharge_svc.html: protect set_usage and
+ reset_usage here, too
+
+2010-06-28 18:01 jeff
+
+ * httemplate/edit/process/svc_acct.cgi: protect call to set_usage
+
+2010-06-28 15:40 ivan
+
+ * FS/FS/part_pkg/voip_cdr.pm: fix for new svc_pbx.title CDR
+ matching, thanks jeff, RT#8084
+
+2010-06-28 15:20 jeff
+
+ * FS/FS/part_pkg/voip_cdr.pm: connection charge handling which
+ comports with history of module
+
+2010-06-28 13:47 jeff
+
+ * FS/FS/part_pkg/voip_cdr.pm: correct connection charge calculation
+
+2010-06-28 01:12 ivan
+
+ * FS/FS/: Conf.pm, cdr.pm, part_pkg/voip_cdr.pm, svc_pbx.pm:
+ matching CDRs to svc_pbx records by title, RT#8084
+
+2010-06-27 22:13 jeff
+
+ * httemplate/search/report_newtax.html: fix under ie8 (and others?)
+ RT8274
+
+2010-06-27 21:11 jeff
+
+ * FS/FS/: cust_bill.pm, usage_class.pm: add some dollar signs
+ RT8704
+
+2010-06-27 02:25 jeff
+
+ * FS/FS/: cust_bill.pm, cust_bill_pkg.pm, usage_class.pm: planet
+ telesis invoice fixups RT 8707,8406
+
+2010-06-26 13:54 ivan
+
+ * httemplate/misc/: maestro-customer_status.cgi,
+ maestro-customer_status.html: adding more REST-like API for
+ maestro here too
+
+2010-06-26 13:50 ivan
+
+ * FS/FS/Maestro.pm: find svc_pbx service correctly and cope anyway
+ if it isn't there, RT#8712
+
+2010-06-26 13:46 ivan
+
+ * FS/FS/Maestro.pm: cancelled outboudn package doesn't count,
+ RT#8712
+
+2010-06-26 13:43 ivan
+
+ * FS/FS/Maestro.pm: finding services correctly for RT#8712
+
+2010-06-26 02:34 ivan
+
+ * FS/FS/Conf.pm, FS/FS/Maestro.pm, FS/FS/Schema.pm,
+ FS/FS/XMLRPC.pm, FS/FS/svc_pbx.pm, FS/MANIFEST,
+ httemplate/misc/xmlrpc.cgi: maestro cust status as reqeusted,
+ RT#8712
+
+2010-06-24 01:17 jeff
+
+ * FS/FS/cust_bill.pm: get section subtotalling right
+
+2010-06-23 16:45 ivan
+
+ * FS/FS/part_export/communigate_pro.pm,
+ httemplate/view/svc_forward.cgi: add display of forward
+ destionations via GetForward, RT#7083
+
+2010-06-23 16:22 ivan
+
+ * httemplate/view/svc_domain/acct_defaults.html: spealing
+
+2010-06-23 16:06 ivan
+
+ * FS/FS/part_export/communigate_pro.pm: fix domain renames, RT#7083
+
+2010-06-23 15:48 ivan
+
+ * httemplate/elements/communigate_pro-accessmodes.html: add
+ services as per customer, RT#7083
+
+2010-06-23 15:19 ivan
+
+ * httemplate/elements/communigate_pro-accessmodes.html: add
+ services as per customer, RT#7083
+
+2010-06-23 13:55 ivan
+
+ * httemplate/misc/cdr-post.cgi: well-formed CSV on success, too,
+ RT#8906
+
+2010-06-23 13:55 ivan
+
+ * FS/FS/cdr.pm: parse text startdate and enddate in CDRs, RT#8906
+
+2010-06-23 13:53 ivan
+
+ * httemplate/misc/cdr-post.cgi: strict CSV when errors contain an
+ ", RT#8906
+
+2010-06-23 13:51 ivan
+
+ * httemplate/misc/cdr-post.cgi: fix cdrbatch problem, RT#8906
+
+2010-06-23 01:37 jeff
+
+ * FS/FS/Conf.pm, FS/FS/cust_bill.pm, conf/invoice_latex,
+ conf/invoice_latexcoupon, httemplate/config/config-process.cgi:
+ add config variables to position invoice addresses in envelope
+ windows RT8384
+
+2010-06-21 21:15 ivan
+
+ * FS/FS/: ClientAPI/MyAccount.pm, TicketSystem/RT_Internal.pm:
+ remove extra debugging, RT#7780
+
+2010-06-21 18:26 ivan
+
+ * FS/bin/freeside-selfservice-xmlrpcd: fix (probably harmless)
+ "DBD::Pg::db disconnect failed: server closed the connection
+ unexpectedly" warning, RT#7780
+
+2010-06-21 18:20 ivan
+
+ * FS/FS/TicketSystem/RT_Internal.pm: enable debugging for
+ create_ticket call to pinpoint lockup, RT#7780
+
+2010-06-21 17:52 ivan
+
+ * FS/FS/ClientAPI/MyAccount.pm: enable debugging for create_ticket
+ call to pinpoint lockup
+
+2010-06-20 16:20 ivan
+
+ * httemplate/elements/communigate_pro-accessmodes.html: new
+ communigate pro accessmodes choices... not 100% sure on the
+ names, RT#7083
+
+2010-06-20 15:50 ivan
+
+ * rt/etc/: RT_Config.pm, RT_Config.pm.in, RT_SiteConfig.pm: restore
+ email addresses in RT 3.8, whew
+
+2010-06-19 13:58 ivan
+
+ * FS/FS/cust_main.pm: daily (bill/collect) optimization, RT#6802
+
+2010-06-19 12:29 ivan
+
+ * FS/FS/part_export/: domreg_opensrs.pm: return rather than ignore
+ errors inserting into the job queue during
+ _export_insert_on_payment
+
+2010-06-19 11:59 ivan
+
+ * FS/FS/cust_bill_ApplicationCommon.pm: should fix
+ FS::svc_acct=HASH(0xe854058) errors inserting payments, fallout
+ from opensrs on #5825, RT#8853
+
+2010-06-17 13:31 ivan
+
+ * httemplate/view/cust_main/misc.html: fix bug from "View customers
+ of all agents" addition
+
+2010-06-17 12:49 ivan
+
+ * FS/FS/payinfo_Mixin.pm: further CF fixes, doh
+
+2010-06-17 12:45 ivan
+
+ * FS/FS/cust_pay.pm: fix fallout from N/A payinfo, RT#8809
+
+2010-06-17 12:39 ivan
+
+ * FS/FS/cust_main.pm: fix cards being inserted as N/A (fallout from
+ RT#4103), RT#8809
+
+2010-06-17 10:50 jeff
+
+ * FS/FS/svc_acct.pm: fix null option with placeholders
+
+2010-06-17 10:19 ivan
+
+ * FS/bin/freeside-selfservice-xmlrpcd: just go ahead and depend on
+ POE 1.2 seems the path of least resistance, RT#7780
+
+2010-06-17 10:18 ivan
+
+ * FS/bin/freeside-selfservice-xmlrpcd: ok
+
+2010-06-16 22:19 ivan
+
+ * FS/bin/freeside-selfservice-xmlrpcd: lenny POE compat, RT#7780
+
+2010-06-16 19:08 ivan
+
+ * FS/FS/AccessRight.pm, FS/FS/access_user.pm,
+ httemplate/browse/part_event.html,
+ httemplate/browse/part_pkg.cgi,
+ httemplate/edit/prospect_main.html,
+ httemplate/edit/process/elements/process.html,
+ httemplate/view/prospect_main.html,
+ httemplate/view/cust_main/misc.html,
+ httemplate/view/svc_acct/tr.html: Add "View customers of all
+ agents" access rights, RT#7010
+
+2010-06-16 18:17 ivan
+
+ * httemplate/view/cust_main/misc.html: better agent virt
+
+2010-06-16 18:17 ivan
+
+ * httemplate/misc/process/payment.cgi: style
+
+2010-06-16 18:16 ivan
+
+ * httemplate/misc/delete-cgp_rule.html: fix comment
+
+2010-06-16 18:16 ivan
+
+ * httemplate/edit/process/elements/svc_Common.html: new-style mason
+
+2010-06-16 18:15 ivan
+
+ * httemplate/edit/elements/edit.html: slighly better error message
+ when things go awry
+
+2010-06-16 18:15 ivan
+
+ * httemplate/edit/cust_main/birthdate.html: indentation
+
+2010-06-16 18:15 ivan
+
+ * httemplate/edit/prospect_main.html: remove debugging
+
+2010-06-16 18:14 ivan
+
+ * httemplate/edit/cust_main.cgi: "Customer" not necessary as part
+ of label, KISS
+
+2010-06-16 18:14 ivan
+
+ * httemplate/index.html: enable dashboard-install_welcome
+
+2010-06-16 14:53 ivan
+
+ * FS/FS/Conf.pm, FS/FS/Daemon.pm,
+ FS/bin/freeside-selfservice-xmlrpcd, init.d/freeside-init: a
+ local XML-RPC server for ncic: daemonize and respond to TERM,
+ RT#7780
+
+2010-06-16 12:41 ivan
+
+ * FS/: bin/freeside-selfservice-xmlrpcd, FS/Daemon.pm: a local
+ XML-RPC server for ncic: daemonize and respond to TERM, RT#7780
+
+2010-06-16 01:42 ivan
+
+ * FS/bin/freeside-selfservice-xmlrpcd: a local XML-RPC server for
+ ncic: cleanup as a modern POE app, RT#7780
+
+2010-06-16 00:50 ivan
+
+ * FS/MANIFEST, FS/FS/ClientAPI_XMLRPC.pm,
+ FS/bin/freeside-selfservice-xmlrpcd,
+ fs_selfservice/perl/xmlrpc_local-phonenum_balance.pl: start of a
+ local XML-RPC server for ncic, RT#7780
+
+2010-06-15 19:19 mark
+
+ * FS/FS/pay_batch.pm, httemplate/elements/file-upload.html,
+ httemplate/misc/upload-batch.cgi,
+ httemplate/search/cust_pay_batch.cgi: RT#5683: payment batch
+ upload uses job queue and progressbar
+
+2010-06-14 23:40 ivan
+
+ * httemplate/search/rt_transaction.html: ticket # and
+ account(customer) options for time worked report
+
+2010-06-14 23:35 ivan
+
+ * httemplate/search/rt_transaction.html: ticket # and
+ account(customer) options for time worked report
+
+2010-06-14 23:06 ivan
+
+ * httemplate/search/: report_rt_transaction.html,
+ rt_transaction.html: ticket # and account(customer) options for
+ time worked report
+
+2010-06-14 22:10 mark
+
+ * FS/FS/: Daemon.pm, Misc.pm: RT#7869: fix error messages when
+ send_email fails in the job queue
+
+2010-06-14 21:50 ivan
+
+ * FS/FS/cust_main.pm: fix refunds on 2.1 (fallout from webpay bs on
+ RT#4103), RT#8700
+
+2010-06-13 11:01 jeff
+
+ * FS/FS/svc_acct.pm: avoid sql injection
+
+2010-06-12 22:59 jeff
+
+ * FS/FS/ClientAPI/: MyAccount.pm, Signup.pm: eliminate use of
+ 'realtime' arg in calling FS::cust_main_collect RT#4167
+
+2010-06-11 14:17 mark
+
+ * FS/FS/cust_main.pm, FS/FS/cust_pay.pm, FS/FS/cust_refund.pm,
+ httemplate/search/unapplied_cust_pay.html,
+ httemplate/search/elements/cust_main_dayranges.html: RT#7266:
+ continue fixing aging reports
+
+2010-06-10 21:44 mark
+
+ * bin/apache.export: fix my mistake
+
+2010-06-10 21:41 mark
+
+ * bin/merge-user: RT#8691: script to merge usernums
+
+2010-06-10 21:18 mark
+
+ * httemplate/edit/cust_main.cgi: Fix otaker -> usernum issue
+
+2010-06-10 21:14 mark
+
+ * bin/: apache.export, merge-user: RT#8691: script to merge
+ usernums
+
+2010-06-09 21:26 jeff
+
+ * httemplate/search/cdr.html: correcting a wild misspelling?
+
+2010-06-08 15:30 mark
+
+ * httemplate/view/bill_batch.cgi: spurious warning
+
+2010-06-08 15:24 mark
+
+ * FS/FS/Conf.pm, FS/FS/Mason.pm, FS/FS/Schema.pm,
+ FS/FS/cust_bill.pm, FS/FS/bill_batch.pm,
+ FS/FS/cust_bill_batch.pm, FS/FS/cust_bill_batch_option.pm,
+ httemplate/elements/menu.html,
+ httemplate/elements/progress-init.html,
+ httemplate/misc/process/bill_batch-print.html,
+ httemplate/search/bill_batch.cgi, httemplate/view/bill_batch.cgi:
+ RT#947: batch download of invoice PDFs
+
+2010-06-08 01:24 ivan
+
+ * httemplate/misc/: cdr-post.cgi, cdr-post.html: add programmatic
+ CDR posting, RT#8201
+
+2010-06-07 23:58 ivan
+
+ * FS/FS/cdr.pm: respect date_format w/CDRs
+
+2010-06-07 10:44 ivan
+
+ * FS/FS/cust_main.pm: revert debugging from fixing agent payment
+ gateway overrides not working in 2.1, RT#8695
+
+2010-06-07 10:32 ivan
+
+ * FS/FS/agent.pm: fix agent payment gateway overrides not working
+ in 2.1, RT#8695
+
+2010-06-07 10:15 ivan
+
+ * FS/FS/cust_main.pm: add debugging
+
+2010-06-06 23:52 ivan
+
+ * httemplate/: elements/freeside.css, elements/menubar.html,
+ view/cust_main.cgi: a little UI goes a long way: have the
+ customer tabs actually enclose their view
+
+2010-06-06 21:35 ivan
+
+ * Makefile: next ver
+
+2010-06-06 19:39 ivan
+
+ * httemplate/misc/clone-cgp_rule.html,
+ httemplate/browse/cgp_rule.html, FS/FS/Conf.pm,
+ FS/FS/cgp_rule.pm, httemplate/edit/process/elements/process.html,
+ httemplate/edit/process/cgp_rule.html: domain rules based on
+ templates (rules from other domains), RT#7514
+
+2010-06-06 17:09 ivan
+
+ * FS/FS/Schema.pm, FS/FS/cgp_rule_condition.pm,
+ httemplate/elements/select-cgp_rule_condition.html,
+ httemplate/edit/cgp_rule.html, httemplate/browse/cgp_rule.html,
+ httemplate/edit/process/cgp_rule.html, FS/bin/freeside-upgrade:
+ mysql compat: cgp_rule_condition s/condition/conditionname/
+
+2010-06-05 23:29 ivan
+
+ * httemplate/edit/process/: access_group.html, access_user.html,
+ agent.cgi: webdemo UI
+
+2010-06-05 23:24 ivan
+
+ * httemplate/edit/process/access_user.html: fix disable_acl_changes
+ on users
+
+2010-06-05 23:19 ivan
+
+ * httemplate/config/config-process.cgi, FS/FS/Mason.pm: UI
+
+2010-06-05 23:05 ivan
+
+ * httemplate/: edit/process/access_user.html,
+ pref/pref-process.html: better disable_acl_changes
+
+2010-06-05 22:58 ivan
+
+ * FS/FS/Conf.pm, httemplate/config/config-delete.cgi,
+ httemplate/config/config-process.cgi: add
+ disable_settings_changes conf for the demo
+
+2010-06-05 21:22 ivan
+
+ * rt/lib/RT/Config.pm: disable the RTAddressRegexp option for now;
+ waaaaaaay too noise
+
+2010-06-05 20:01 ivan
+
+ * httemplate/search/: sql.html, elements/search.html: fix sql
+ query, RT#8035
+
+2010-06-05 19:27 ivan
+
+ * FS/FS/cust_main.pm: log customer with queued billing jobs,
+ RT#8282
+
+2010-06-05 19:24 ivan
+
+ * FS/FS/cust_main.pm: oops, missed CF changes
+
+2010-06-05 14:44 ivan
+
+ * FS/FS/otaker_Mixin.pm: should really really fix the
+ cust_main_note upgrade bullshit, argh, RT#8580
+
+2010-06-05 13:50 ivan
+
+ * FS/: bin/freeside-upgrade, FS/Upgrade.pm: should really fix bug
+ commiting between each table upgrade, arg, RT#8580
+
+2010-06-05 13:30 ivan
+
+ * FS/FS/Upgrade.pm: should really fix bug commiting between each
+ table upgrade, arg, RT#8580
+
+2010-06-05 13:18 ivan
+
+ * FS/FS/otaker_Mixin.pm: should really fix the cust_main_note
+ upgrade bullshit, RT#8580
+
+2010-06-05 12:50 ivan
+
+ * FS/FS/otaker_Mixin.pm: should really fix the cust_main_note
+ upgrade bullshit, RT#8580
+
+2010-06-05 12:47 ivan
+
+ * FS/FS/Upgrade.pm: should fix bug commiting between each table
+ upgrade, RT#8580
+
+2010-06-05 11:58 ivan
+
+ * FS/FS/otaker_Mixin.pm: yuck, deal with those screwed up otakers
+ in cust_main_note, RT#8580
+
+2010-06-05 10:31 ivan
+
+ * FS/FS/cust_credit.pm: ignore misapplied credits when just trying
+ upgrade the otaker, RT#8580
+
+2010-06-04 22:54 jeff
+
+ * httemplate/search/: 477.html, 477partV.html, 477partVI.html,
+ elements/search-html.html: fix urls
+
+2010-06-04 22:51 jeff
+
+ * FS/FS/part_pkg_taxrate.pm: don't delete too much RT#8581
+
+2010-06-03 09:20 jeff
+
+ * FS/FS/cust_main.pm: repair botched refactor start during BOTPP
+ integration RT# 8600
+
+2010-06-01 12:58 jeff
+
+ * FS/FS/CGI.pm, FS/FS/queue.pm, FS/FS/tax_rate.pm, FS/FS/UI/Web.pm,
+ httemplate/elements/progress-popup.html,
+ httemplate/search/report_newtax.html,
+ httemplate/search/report_queued_newtax.cgi: add progressbar,
+ redirection, and improve links RT#8274
+
+2010-06-01 11:53 mark
+
+ * httemplate/search/: h_inventory_item.html,
+ report_h_inventory_item.html: RT#8460 improvements
+
+2010-06-01 10:40 mark
+
+ * httemplate/search/h_inventory_item.html: RT#8460: monthly opening
+ balance
+
+2010-06-01 09:52 mark
+
+ * FS/FS/cdr/wip.pm: RT#8026: skip line charges when importing WIP
+ CDRs
+
+2010-05-28 00:51 mark
+
+ * httemplate/search/cust_pkg.cgi: RT#8465: add service label to
+ downloadable package reports
+
+2010-05-26 18:02 mark
+
+ * httemplate/search/elements/search-html.html: unbreak download
+ links
+
+2010-05-26 11:37 mark
+
+ * httemplate/search/rt_transaction.html: broken link in time worked
+ report
+
+2010-05-26 09:11 jeff
+
+ * FS/FS/tax_rate.pm: correct uncorrected tyop
+
+2010-05-26 00:12 mark
+
+ * FS/FS/: Mason.pm, h_inventory_item.pm: RT#8460: inventory
+ activity report
+
+2010-05-25 23:39 mark
+
+ * httemplate/: elements/menu.html, search/h_inventory_item.html,
+ search/report_h_inventory_item.html: RT#8460: inventory activity
+ report
+
+2010-05-25 05:43 ivan
+
+ * ChangeLog, debian/changelog: Updated for 2.1.0
+
2010-05-25 05:42 ivan
* httemplate/elements/dashboard-install_welcome.html: adding,
@@ -27690,11 +29932,6 @@
* FS/FS/cust_pkg.pm: add last_bill field to manpage
-2003-05-30 02:40 ivan
-
- * httemplate/docs/man/FS/part_export/.cvs_is_on_crack: force
- inclusion of httemplate/docs/man hierarchy
-
2003-05-30 02:22 ivan
* FS/FS/part_export/sqlradius.pm: sqlradius exports include "op"
@@ -32252,10 +34489,6 @@
* httemplate/docs/install.html: doc update
-2002-02-04 09:12 ivan
-
- * httemplate/docs/man/FS/: Bill.html, Invoice.html: ancient files
-
2002-02-04 09:06 ivan
* Makefile, README.1.4.0pre4567-8, README.1.4.0pre8: 1.4.0pre8!
@@ -32344,60 +34577,11 @@
new CGI; &cgisuidsetup(); from all templates. should work
better under Mason.
-2002-01-29 09:46 ivan
-
- * httemplate/docs/man/: FS.html, FS/CGI.html, FS/CGIwrapper.html,
- FS/Conf.html, FS/Record.html, FS/SessionClient.html,
- FS/SignupClient.html, FS/UID.html, FS/agent.html,
- FS/agent_type.html, FS/cust_bill.html, FS/cust_bill_pkg.html,
- FS/cust_credit.html, FS/cust_main.html, FS/cust_main_county.html,
- FS/cust_main_invoice.html, FS/cust_pay.html,
- FS/cust_pay_batch.html, FS/cust_pkg.html, FS/cust_refund.html,
- FS/cust_svc.html, FS/domain_record.html, FS/nas.html,
- FS/part_pkg.html, FS/part_referral.html, FS/part_svc.html,
- FS/pkg_svc.html, FS/port.html, FS/prepay_credit.html,
- FS/session.html, FS/svc_Common.html, FS/svc_acct.html,
- FS/svc_acct_pop.html, FS/svc_acct_sm.html, FS/svc_domain.html,
- FS/svc_www.html, FS/type_pkgs.html: these are all auto-generated
- by the installation; don't check them into CVS (probably should
- generate them for a release tarball though)
-
2002-01-29 09:42 ivan
* README.1.4.0pre4567-8, FS/FS/part_bill_event.pm, bin/fs-setup,
- httemplate/docs/schema.html, httemplate/docs/upgrade8.html,
- httemplate/docs/man/FS/SessionClient.html,
- httemplate/docs/man/FS/UID.html,
- httemplate/docs/man/FS/agent.html,
- httemplate/docs/man/FS/agent_type.html,
- httemplate/docs/man/FS/cust_bill.html,
- httemplate/docs/man/FS/cust_bill_pkg.html,
- httemplate/docs/man/FS/cust_credit.html,
- httemplate/docs/man/FS/cust_main.html,
- httemplate/docs/man/FS/cust_main_county.html,
- httemplate/docs/man/FS/cust_main_invoice.html,
- httemplate/docs/man/FS/cust_pay.html,
- httemplate/docs/man/FS/cust_pay_batch.html,
- httemplate/docs/man/FS/cust_pkg.html,
- httemplate/docs/man/FS/cust_refund.html,
- httemplate/docs/man/FS/cust_svc.html,
- httemplate/docs/man/FS/domain_record.html,
- httemplate/docs/man/FS/nas.html,
- httemplate/docs/man/FS/part_pkg.html,
- httemplate/docs/man/FS/part_referral.html,
- httemplate/docs/man/FS/part_svc.html,
- httemplate/docs/man/FS/pkg_svc.html,
- httemplate/docs/man/FS/port.html,
- httemplate/docs/man/FS/prepay_credit.html,
- httemplate/docs/man/FS/session.html,
- httemplate/docs/man/FS/svc_Common.html,
- httemplate/docs/man/FS/svc_acct.html,
- httemplate/docs/man/FS/svc_acct_pop.html,
- httemplate/docs/man/FS/svc_acct_sm.html,
- httemplate/docs/man/FS/svc_domain.html,
- httemplate/docs/man/FS/svc_www.html,
- httemplate/docs/man/FS/type_pkgs.html: weight, plan and plandata
- fields in part_bill_event
+ httemplate/docs/schema.html, httemplate/docs/upgrade8.html:
+ weight, plan and plandata fields in part_bill_event
2002-01-29 08:33 ivan
@@ -32405,42 +34589,7 @@
FS/FS/cust_pay.pm, FS/FS/cust_pkg.pm, FS/FS/cust_svc.pm,
FS/FS/svc_acct.pm, bin/pod2x, htetc/global.asa, htetc/handler.pl,
httemplate/index.html, httemplate/browse/part_bill_event.cgi,
- httemplate/docs/install.html, httemplate/docs/man/FS.html,
- httemplate/docs/man/FS/CGI.html,
- httemplate/docs/man/FS/Conf.html,
- httemplate/docs/man/FS/Record.html,
- httemplate/docs/man/FS/SessionClient.html,
- httemplate/docs/man/FS/SignupClient.html,
- httemplate/docs/man/FS/UID.html,
- httemplate/docs/man/FS/agent.html,
- httemplate/docs/man/FS/agent_type.html,
- httemplate/docs/man/FS/cust_bill.html,
- httemplate/docs/man/FS/cust_bill_pkg.html,
- httemplate/docs/man/FS/cust_credit.html,
- httemplate/docs/man/FS/cust_main.html,
- httemplate/docs/man/FS/cust_main_county.html,
- httemplate/docs/man/FS/cust_main_invoice.html,
- httemplate/docs/man/FS/cust_pay.html,
- httemplate/docs/man/FS/cust_pay_batch.html,
- httemplate/docs/man/FS/cust_pkg.html,
- httemplate/docs/man/FS/cust_refund.html,
- httemplate/docs/man/FS/cust_svc.html,
- httemplate/docs/man/FS/domain_record.html,
- httemplate/docs/man/FS/nas.html,
- httemplate/docs/man/FS/part_pkg.html,
- httemplate/docs/man/FS/part_referral.html,
- httemplate/docs/man/FS/part_svc.html,
- httemplate/docs/man/FS/pkg_svc.html,
- httemplate/docs/man/FS/port.html,
- httemplate/docs/man/FS/prepay_credit.html,
- httemplate/docs/man/FS/session.html,
- httemplate/docs/man/FS/svc_Common.html,
- httemplate/docs/man/FS/svc_acct.html,
- httemplate/docs/man/FS/svc_acct_pop.html,
- httemplate/docs/man/FS/svc_acct_sm.html,
- httemplate/docs/man/FS/svc_domain.html,
- httemplate/docs/man/FS/svc_www.html,
- httemplate/docs/man/FS/type_pkgs.html,
+ httemplate/docs/install.html,
httemplate/edit/part_bill_event.cgi,
httemplate/edit/part_pkg.cgi,
httemplate/edit/process/part_bill_event.cgi: - web interface for
@@ -34359,45 +36508,7 @@
httemplate/docs/upgrade3.html, httemplate/docs/upgrade4.html,
httemplate/docs/upgrade5.html, httemplate/docs/upgrade6.html,
httemplate/docs/upgrade7.html, httemplate/docs/upgrade8.html,
- httemplate/docs/man/FS.html, httemplate/docs/man/FS/Bill.html,
- httemplate/docs/man/FS/CGI.html,
- httemplate/docs/man/FS/CGIwrapper.html,
- httemplate/docs/man/FS/Conf.html,
- httemplate/docs/man/FS/Invoice.html,
- httemplate/docs/man/FS/Record.html,
- httemplate/docs/man/FS/SessionClient.html,
- httemplate/docs/man/FS/SignupClient.html,
- httemplate/docs/man/FS/UID.html,
- httemplate/docs/man/FS/agent.html,
- httemplate/docs/man/FS/agent_type.html,
- httemplate/docs/man/FS/cust_bill.html,
- httemplate/docs/man/FS/cust_bill_pkg.html,
- httemplate/docs/man/FS/cust_credit.html,
- httemplate/docs/man/FS/cust_main.html,
- httemplate/docs/man/FS/cust_main_county.html,
- httemplate/docs/man/FS/cust_main_invoice.html,
- httemplate/docs/man/FS/cust_pay.html,
- httemplate/docs/man/FS/cust_pay_batch.html,
- httemplate/docs/man/FS/cust_pkg.html,
- httemplate/docs/man/FS/cust_refund.html,
- httemplate/docs/man/FS/cust_svc.html,
- httemplate/docs/man/FS/domain_record.html,
- httemplate/docs/man/FS/nas.html,
- httemplate/docs/man/FS/part_pkg.html,
- httemplate/docs/man/FS/part_referral.html,
- httemplate/docs/man/FS/part_svc.html,
- httemplate/docs/man/FS/pkg_svc.html,
- httemplate/docs/man/FS/port.html,
- httemplate/docs/man/FS/prepay_credit.html,
- httemplate/docs/man/FS/session.html,
- httemplate/docs/man/FS/svc_Common.html,
- httemplate/docs/man/FS/svc_acct.html,
- httemplate/docs/man/FS/svc_acct_pop.html,
- httemplate/docs/man/FS/svc_acct_sm.html,
- httemplate/docs/man/FS/svc_domain.html,
- httemplate/docs/man/FS/svc_www.html,
- httemplate/docs/man/FS/type_pkgs.html, bin/fs-setup,
- bin/masonize, bin/pod2x, httemplate/edit/agent.cgi,
+ bin/fs-setup, bin/masonize, bin/pod2x, httemplate/edit/agent.cgi,
httemplate/edit/agent_type.cgi, httemplate/edit/cust_credit.cgi,
httemplate/edit/cust_main.cgi,
httemplate/edit/cust_main_county-expand.cgi,
diff --git a/FS/FS.pm b/FS/FS.pm
index 07b31b3..1fdde35 100644
--- a/FS/FS.pm
+++ b/FS/FS.pm
@@ -61,6 +61,8 @@ L<FS::ClientAPI_SessionCache> - ClientAPI session cache
L<FS::Pony> - A pony
+L<FS::cust_main::Search> - Customer searching
+
L<FS::cust_main::Import> - Batch customer importing
=head2 Database record classes
@@ -262,6 +264,12 @@ L<FS::prospect_main> - Prospect class
L<FS::cust_main> - Customer class
+L<FS::cust_main::Billing> - Customer billing class
+
+L<FS::cust_main::Billing_Realtime> - Customer real-time billing class
+
+L<FS::cust_main::Packages> - Customer packages class
+
L<FS::cust_location> - Customer location class
L<FS::cust_main_Mixin> - Mixin class for records that contain fields from cust_main
diff --git a/FS/FS/AccessRight.pm b/FS/FS/AccessRight.pm
index 92c4d22..e7d77b5 100644
--- a/FS/FS/AccessRight.pm
+++ b/FS/FS/AccessRight.pm
@@ -113,6 +113,7 @@ tie my %rights, 'Tie::IxHash',
'View customer history',
'Cancel customer',
'Complimentary customer', #aka users-allow_comp
+ 'Merge customer',
{ 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
@@ -182,15 +183,16 @@ tie my %rights, 'Tie::IxHash',
# customer payment rights
###
'Customer payment rights' => [
- 'Post payment',
+ { rightname=>'Post payment', desc=>'Make check or cash payments.' },
+ 'Post check payment',
+ 'Post cash payment',
'Post payment batch',
'Apply payment', #NEWNEW
{ rightname=>'Unapply payment', desc=>'Enable "unapplication" of unclosed payments from specific invoices.' }, #aka. unapplypayments
- 'Process payment',
- { rightname=>'Refund payment', desc=>'Enable refund of existing customer payments.' },
-
+ { rightname=>'Process payment', desc=>'Process credit card or e-check payments' },
+ 'Process credit card payment',
+ 'Process Echeck payment',
{ rightname=>'Delete payment', desc=>'Enable deletion of unclosed payments. Be very careful! Only delete payments that were data-entry errors, not adjustments.' }, #aka. deletepayments Optionally specify one or more comma-separated email addresses to be notified when a payment is deleted.
-
],
###
@@ -202,7 +204,12 @@ tie my %rights, 'Tie::IxHash',
{ rightname=>'Unapply credit', desc=>'Enable "unapplication" of unclosed credits.' }, #aka unapplycredits
{ rightname=>'Delete credit', desc=>'Enable deletion of unclosed credits. Be very careful! Only delete credits that were data-entry errors, not adjustments.' }, #aka. deletecredits Optionally specify one or more comma-separated email addresses to be notified when a credit is deleted.
{ rightname=>'Post refund', desc=>'Enable posting of check and cash refunds.' },
+ 'Post check refund',
+ 'Post cash refund',
# { rightname=>'Process refund', desc=>'Enable processing of generic credit card/ACH refunds (i.e. not associated with a specific prior payment).' },
+ { rightname=>'Refund payment', desc=>'Enable refund of existing customer credit card or e-check payments.' },
+ 'Refund credit card payment',
+ 'Refund Echeck payment',
'Delete refund', #NEW
'Add on-the-fly credit reason', #NEW
],
@@ -346,6 +353,8 @@ sub default_superuser_rights {
'Redownload resolved batches',
'Raw SQL',
'Configuration download',
+ 'View customers of all agents',
+ 'View/link unlinked services',
);
no warnings 'uninitialized';
diff --git a/FS/FS/ClientAPI/Agent.pm b/FS/FS/ClientAPI/Agent.pm
index daede59..e1624b9 100644
--- a/FS/FS/ClientAPI/Agent.pm
+++ b/FS/FS/ClientAPI/Agent.pm
@@ -9,7 +9,7 @@ use Digest::MD5 qw(md5_hex);
use FS::Record qw(qsearchs); # qsearch dbdef dbh);
use FS::ClientAPI_SessionCache;
use FS::agent;
-use FS::cust_main qw(smart_search);
+use FS::cust_main::Search qw(smart_search);
sub _cache {
$cache ||= new FS::ClientAPI_SessionCache( {
@@ -17,6 +17,31 @@ sub _cache {
} );
}
+sub new_agent {
+ my $p = shift;
+
+ my $conf = new FS::Conf;
+ return { error=>'Disabled' } unless $conf->exists('selfservice-agent_signup');
+
+ #add a customer record and set agent_custnum?
+
+ my $agent = new FS::agent {
+ 'typenum' => $conf->config('selfservice-agent_signup-agent_type'),
+ 'agent' => $p->{'agent'},
+ 'username' => $p->{'username'},
+ '_password' => $p->{'password'},
+ #
+ };
+
+ my $error = $agent->insert;
+
+ return { 'error' => $error } if $error;
+
+ agent_login({ 'username' => $p->{'username'},
+ 'password' => $p->{'password'},
+ });
+}
+
sub agent_login {
my $p = shift;
diff --git a/FS/FS/ClientAPI/MyAccount.pm b/FS/FS/ClientAPI/MyAccount.pm
index 8003613..1e1d1d3 100644
--- a/FS/FS/ClientAPI/MyAccount.pm
+++ b/FS/FS/ClientAPI/MyAccount.pm
@@ -357,6 +357,11 @@ sub customer_info {
$return{support_services} = \@support_services;
}
+ if ( $conf->config('prepayment_discounts-credit_type') ) {
+ #need to eval?
+ $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
+ }
+
} elsif ( $session->{'svcnum'} ) { #no customer record
my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $session->{'svcnum'} } )
@@ -459,10 +464,10 @@ sub payment_info {
#generic
##
+ my $conf = new FS::Conf;
use vars qw($payment_info); #cache for performance
unless ( $payment_info ) {
- my $conf = new FS::Conf;
my %states = map { $_->state => 1 }
qsearch('cust_main_county', {
'country' => $conf->config('countrydefault') || 'US'
@@ -555,6 +560,11 @@ sub payment_info {
}
+ if ( $conf->config('prepayment_discounts-credit_type') ) {
+ #need to eval?
+ $return{discount_terms_hash} = { $cust_main->discount_terms_hash };
+ }
+
#doubleclick protection
my $_date = time;
$return{paybatch} = "webui-MyAccount-$_date-$$-". rand() * 2**32;
@@ -586,6 +596,10 @@ sub process_payment {
my $amount = $1;
return { error => 'Amount must be greater than 0' } unless $amount > 0;
+ $p->{'discount_term'} =~ /^\s*(\d*)\s*$/
+ or return { 'error' => gettext('illegal_discount_term'). ': '. $p->{'discount_term'} };
+ my $discount_term = $1;
+
$p->{'payname'} =~ /^([\w \,\.\-\']+)$/
or return { 'error' => gettext('illegal_name'). " payname: ". $p->{'payname'} };
my $payname = $1;
@@ -664,6 +678,7 @@ sub process_payment {
'paybatch' => $paybatch, #this doesn't actually do anything
'paycvv' => $paycvv,
'pkgnum' => $session->{'pkgnum'},
+ 'discount_term' => $discount_term,
map { $_ => $p->{$_} } @{ $payby2fields{$payby} }
);
return { 'error' => $error } if $error;
@@ -1181,6 +1196,7 @@ sub order_pkg {
'svc_domain' => [ qw( domain ) ],
'svc_phone' => [ qw( phonenum pin sip_password phone_name ) ],
'svc_external' => [ qw( id title ) ],
+ 'svc_pbx' => [ qw( id name ) ],
);
my $svc_x = "FS::$svcdb"->new( {
@@ -1237,7 +1253,9 @@ sub order_pkg {
$cust_pkg->reexport;
}
- return { error => '', pkgnum => $cust_pkg->pkgnum };
+ my $svcnum = $svc[0] ? $svc[0]->svcnum : '';
+
+ return { error=>'', pkgnum=>$cust_pkg->pkgnum, svcnum=>$svcnum };
}
@@ -1725,6 +1743,7 @@ sub _custoragent_session_custnum {
$custnum = $p->{'custnum'};
} else {
+ $context = 'error';
return ( 'error' => "Can't resume session" ); #better error message
}
diff --git a/FS/FS/ClientAPI/Signup.pm b/FS/FS/ClientAPI/Signup.pm
index 5d70325..65bb4e3 100644
--- a/FS/FS/ClientAPI/Signup.pm
+++ b/FS/FS/ClientAPI/Signup.pm
@@ -26,6 +26,15 @@ use FS::payby;
$DEBUG = 0;
$me = '[FS::ClientAPI::Signup]';
+sub clear_cache {
+ warn "$me clear_cache called\n" if $DEBUG;
+ my $cache = new FS::ClientAPI_SessionCache( {
+ 'namespace' => 'FS::ClientAPI::Signup',
+ } );
+ $cache->clear();
+ return {};
+}
+
sub signup_info {
my $packet = shift;
@@ -84,13 +93,37 @@ sub signup_info {
my @agent_fields = qw( agentnum agent );
+ my @bools = qw( emailinvoiceonly security_phrase );
+
+ my @signup_bools = qw( no_company recommend_daytime recommend_email );
+
+ my @signup_server_scalars = qw( default_pkgpart default_svcpart );
+
+ my @selfservice_textareas = qw( head body_header body_footer );
+
+ my @selfservice_scalars = qw(
+ body_bgcolor box_bgcolor
+ text_color link_color vlink_color hlink_color alink_color
+ font title_color title_align title_size menu_bgcolor menu_fontsize
+ );
+
+ #XXX my @selfservice_bools = qw(
+ # menu_skipblanks menu_skipheadings menu_nounderline
+ #);
+
+ #my $selfservice_binaries = qw(
+ # title_left_image title_right_image
+ # menu_top_image menu_body_image menu_bottom_image
+ #);
+
$signup_info_cache = {
+
'cust_main_county' => [ map $_->hashref,
qsearch('cust_main_county', {} )
],
'agent' => [ map { my $agent = $_;
- map { $_ => $agent->get($_) } @agent_fields;
+ +{ map { $_ => $agent->get($_) } @agent_fields }
}
qsearch('agent', { 'disabled' => '' } )
],
@@ -111,48 +144,49 @@ sub signup_info {
'payby' => [ $conf->config('signup_server-payby') ],
- 'card_types' => card_types(),
-
- 'paytypes' => [ @FS::cust_main::paytypes ],
-
- 'cvv_enabled' => 1,
-
- 'stateid_enabled' => $conf->exists('show_stateid'),
-
- 'paystate_enabled' => $conf->exists('show_bankstate'),
-
- 'ship_enabled' => 1,
-
- 'msgcat' => $msgcat,
-
- 'label' => $label,
-
- 'statedefault' => scalar($conf->config('statedefault')) || 'CA',
-
- 'countrydefault' => scalar($conf->config('countrydefault')) || 'US',
-
- 'refnum' => scalar($conf->config('signup_server-default_refnum')),
-
- 'default_pkgpart' => scalar($conf->config('signup_server-default_pkgpart')),
-
- 'signup_service' => $svc_x,
- 'default_svcpart' => scalar($conf->config('signup_server-default_svcpart')),
-
- 'head' => join("\n", $conf->config('selfservice-head') ),
- 'body_header' => join("\n", $conf->config('selfservice-body_header') ),
- 'body_footer' => join("\n", $conf->config('selfservice-body_footer') ),
- 'body_bgcolor' => scalar( $conf->config('selfservice-body_bgcolor') ),
- 'box_bgcolor' => scalar( $conf->config('selfservice-box_bgcolor') ),
+ 'payby_longname' => [ map { FS::payby->longname($_) }
+ $conf->config('signup_server-payby') ],
- 'company_name' => scalar($conf->config('company_name')),
+ 'card_types' => card_types(),
+ ( map { $_ => $conf->exists("signup-$_") } @signup_bools ),
+
+ ( map { $_ => scalar($conf->config("signup_server-$_")) }
+ @signup_server_scalars
+ ),
+
+ ( map { $_ => join("\n", $conf->config("selfservice-$_")) }
+ @selfservice_textareas
+ ),
+ ( map { $_ => scalar($conf->config("selfservice-$_")) }
+ @selfservice_scalars
+ ),
+
+ #( map { $_ => scalar($conf->config_binary("selfservice-$_")) }
+ # @selfservice_binaries
+ #),
+
+ 'agentnum2part_pkg' => $agentnum2part_pkg,
+ 'svc_acct_pop' => [ map $_->hashref, qsearch('svc_acct_pop',{} ) ],
+ 'nomadix' => $conf->exists('signup_server-nomadix'),
+ 'payby' => [ $conf->config('signup_server-payby') ],
+ 'card_types' => card_types(),
+ 'paytypes' => [ @FS::cust_main::paytypes ],
+ 'cvv_enabled' => 1,
+ 'stateid_enabled' => $conf->exists('show_stateid'),
+ 'paystate_enabled' => $conf->exists('show_bankstate'),
+ 'ship_enabled' => 1,
+ 'msgcat' => $msgcat,
+ 'label' => $label,
+ 'statedefault' => scalar($conf->config('statedefault')) || 'CA',
+ 'countrydefault' => scalar($conf->config('countrydefault')) || 'US',
+ 'refnum' => scalar($conf->config('signup_server-default_refnum')),
+ 'signup_service' => $svc_x,
+ 'company_name' => scalar($conf->config('company_name')),
#per-agent?
'agent_ship_address' => scalar($conf->exists('agent-ship_address')),
-
- 'no_company' => scalar($conf->exists('signup-no_company')),
- 'require_phone' => scalar($conf->exists('cust_main-require_phone')),
- 'recommend_daytime' => scalar($conf->exists('signup-recommend_daytime')),
- 'recommend_email' => scalar($conf->exists('signup-recommend_email')),
+ 'require_phone' => scalar($conf->exists('cust_main-require_phone')),
+ 'logo' => scalar($conf->config_binary('logo.png')),
};
@@ -356,7 +390,7 @@ sub signup_info {
#( map { $_ => scalar( $conf->config($_, $agentnum) ) }
# qw( company_name ) ),
( map { $_ => scalar( $conf->config("selfservice-$_", $agentnum ) ) }
- qw( body_bgcolor box_bgcolor) ),
+ qw( body_bgcolor box_bgcolor menu_bgcolor ) ),
( map { $_ => join("\n", $conf->config("selfservice-$_", $agentnum ) ) }
qw( head body_header body_footer ) ),
};
@@ -383,9 +417,14 @@ sub signup_info {
my $agent_signup_info = { %$signup_info };
delete $agent_signup_info->{agentnum2part_pkg};
$agent_signup_info->{'agent'} = $session->{'agent'};
- $agent_signup_info;
- } else {
- $signup_info;
+ return $agent_signup_info;
+ }
+ elsif ( exists $packet->{'keys'} ) {
+ my @keys = @{ $packet->{'keys'} };
+ return { map { $_ => $signup_info->{$_} } @keys };
+ }
+ else {
+ return $signup_info;
}
}
@@ -434,6 +473,9 @@ sub new_customer {
unless $packet->{'popnum'} || !scalar(qsearch('svc_acct_pop',{} ));
}
+ elsif ( $svc_x eq 'svc_pbx' ) {
+ #possibly some validation will be needed
+ }
my $agentnum;
if ( exists $packet->{'session_id'} ) {
@@ -567,24 +609,27 @@ sub new_customer {
push @acct_snarf, $acct_snarf;
}
$svc->child_objects( \@acct_snarf );
-
push @svc, $svc;
} elsif ( $svc_x eq 'svc_phone' ) {
- my $svc = new FS::svc_phone ( {
+ push @svc, new FS::svc_phone ( {
'svcpart' => $svcpart,
map { $_ => $packet->{$_} }
qw( countrycode phonenum sip_password pin ),
} );
- push @svc, $svc;
+ } elsif ( $svc_x eq 'svc_pbx' ) {
+ push @svc, new FS::svc_pbx ( {
+ 'svcpart' => $svcpart,
+ map { $_ => $packet->{$_} }
+ qw( id title ),
+ } );
+
} else {
die "unknown signup service $svc_x";
}
- my $y = $svc[0]->setdefault; # arguably should be in new method
- return { 'error' => $y } if $y && !ref($y);
if ($packet->{'mac_addr'} && $conf->exists('signup_server-mac_addr_svcparts'))
{
@@ -603,15 +648,16 @@ sub new_customer {
'_password' => '', #blank as requested (set passwordmin to 0)
};
- my $y = $svc->setdefault; # arguably should be in new method
- return { 'error' => $y } if $y && !ref($y);
-
push @svc, $svc;
}
- #$error = $svc->check;
- #return { 'error' => $error } if $error;
+ foreach my $svc ( @svc ) {
+ my $y = $svc->setdefault; # arguably should be in new method
+ return { 'error' => $y } if $y && !ref($y);
+ #$error = $svc->check;
+ #return { 'error' => $error } if $error;
+ }
#setup a job dependancy to delay provisioning
my $placeholder = new FS::queue ( {
@@ -638,14 +684,14 @@ sub new_customer {
if ( $conf->exists('signup_server-realtime') ) {
- #warn "[fs_signup_server] Billing customer...\n" if $Debug;
+ #warn "$me Billing customer...\n" if $Debug;
my $bill_error = $cust_main->bill;
- #warn "[fs_signup_server] error billing new customer: $bill_error"
+ #warn "$me error billing new customer: $bill_error"
# if $bill_error;
$bill_error = $cust_main->apply_payments_and_credits;
- #warn "[fs_signup_server] error applying payments and credits for".
+ #warn "$me error applying payments and credits for".
# " new customer: $bill_error"
# if $bill_error;
@@ -653,7 +699,7 @@ sub new_customer {
method => FS::payby->payby2bop( $packet->{payby} ),
depend_jobnum => $placeholder->jobnum,
);
- #warn "[fs_signup_server] error collecting from new customer: $bill_error"
+ #warn "$me error collecting from new customer: $bill_error"
# if $bill_error;
if ($bill_error && ref($bill_error) eq 'HASH') {
@@ -665,6 +711,11 @@ sub new_customer {
};
}
+ $bill_error = $cust_main->apply_payments_and_credits;
+ #warn "$me error applying payments and credits for".
+ # " new customer: $bill_error"
+ # if $bill_error;
+
if ( $cust_main->balance > 0 ) {
#this makes sense. credit is "un-doing" the invoice
@@ -700,14 +751,25 @@ sub new_customer {
my %return = ( 'error' => '',
'signup_service' => $svc_x,
+ 'custnum' => $cust_main->custnum,
);
- if ( $svc_x eq 'svc_acct' ) {
- $return{$_} = $svc[0]->$_() for qw( username _password );
- } elsif ( $svc_x eq 'svc_phone' ) {
- $return{$_} = $svc[0]->$_() for qw( countrycode phonenum sip_password pin );
- } else {
- die "unknown signup service $svc_x";
+ if ( $svc[0] ) {
+
+ $return{'svcnum'} = $svc[0]->svcnum;
+
+ if ( $svc_x eq 'svc_acct' ) {
+ $return{$_} = $svc[0]->$_() for qw( username _password );
+ } elsif ( $svc_x eq 'svc_phone' ) {
+ $return{$_} = $svc[0]->$_() for qw(countrycode phonenum sip_password pin);
+ } elsif ( $svc_x eq 'svc_pbx' ) {
+ #$return{$_} = $svc[0]->$_() for qw( ) #nothing yet
+ } else {
+ return {'error' => "configuration error: unknown signup service $svc_x"};
+ #die "unknown signup service $svc_x";
+ # return an error that's visible to someone somewhere
+ }
+
}
return \%return;
diff --git a/FS/FS/ClientAPI_XMLRPC.pm b/FS/FS/ClientAPI_XMLRPC.pm
index 138ad06..32e96b8 100644
--- a/FS/FS/ClientAPI_XMLRPC.pm
+++ b/FS/FS/ClientAPI_XMLRPC.pm
@@ -49,7 +49,7 @@ sub AUTOLOAD {
#no strict 'refs';
#&{$call}(@_);
#FS::ClientAPI->dispatch($autoload->{$call}, @_);
- FS::ClientAPI->dispatch($autoload->{$call}, { @_ } );
+ FS::ClientAPI->dispatch($autoload->{$call}, @_ );
}else{
die "No such procedure: $call";
}
@@ -104,6 +104,7 @@ sub ss2clientapi {
'domain_select_hash' => 'Signup/domain_select_hash', # expose?
'new_customer' => 'Signup/new_customer',
'capture_payment' => 'Signup/capture_payment',
+ 'clear_signup_cache' => 'Signup/clear_cache',
'agent_login' => 'Agent/agent_login',
'agent_logout' => 'Agent/agent_logout',
'agent_info' => 'Agent/agent_info',
diff --git a/FS/FS/Conf.pm b/FS/FS/Conf.pm
index ce8bd29..8fce990 100644
--- a/FS/FS/Conf.pm
+++ b/FS/FS/Conf.pm
@@ -395,6 +395,7 @@ sub verify_config_item {
} else {
+ no warnings 'uninitialized';
$error .= "$key fails binary comparison; "
unless scalar($self->config_binary($key)) eq scalar($compat->config_binary($key));
@@ -1181,7 +1182,7 @@ and customer address. Include units.',
'section' => 'invoicing',
'description' => 'Optional default invoice term, used to calculate a due date printed on invoices.',
'type' => 'select',
- 'select_enum' => [ '', 'Payable upon receipt', 'Net 0', 'Net 10', 'Net 15', 'Net 20', 'Net 30', 'Net 45', 'Net 60' ],
+ 'select_enum' => [ '', 'Payable upon receipt', 'Net 0', 'Net 10', 'Net 15', 'Net 20', 'Net 30', 'Net 45', 'Net 60', 'Net 90' ],
},
{
@@ -1241,6 +1242,13 @@ and customer address. Include units.',
},
{
+ 'key' => 'payment_receipt',
+ 'section' => 'notification',
+ 'description' => 'Send payment receipts.',
+ 'type' => 'checkbox',
+ },
+
+ {
'key' => 'payment_receipt_msgnum',
'section' => 'notification',
'description' => 'Template to use for payment receipts.',
@@ -1308,7 +1316,12 @@ and customer address. Include units.',
'editlist_parts' => [ { type=>'text' },
{ type=>'immutable', value=>'IN' },
{ type=>'select',
- select_enum=>{ map { $_=>$_ } qw(A CNAME MX NS TXT)} },
+ select_enum => {
+ map { $_=>$_ }
+ #@{ FS::domain_record->rectypes }
+ qw(A AAAA CNAME MX NS PTR SPF SRV TXT)
+ },
+ },
{ type=> 'text' }, ],
},
@@ -1361,7 +1374,20 @@ and customer address. Include units.',
'key' => 'referraldefault',
'section' => 'UI',
'description' => 'Default referral, specified by refnum',
- 'type' => 'text',
+ 'type' => 'select-sub',
+ 'options_sub' => sub { require FS::Record;
+ require FS::part_referral;
+ map { $_->refnum => $_->referral }
+ FS::Record::qsearch( 'part_referral',
+ { 'disabled' => '' }
+ );
+ },
+ 'option_sub' => sub { require FS::Record;
+ require FS::part_referral;
+ my $part_referral = FS::Record::qsearchs(
+ 'part_referral', { 'refnum'=>shift } );
+ $part_referral ? $part_referral->referral : '';
+ },
},
# {
@@ -1600,6 +1626,20 @@ and customer address. Include units.',
'type' => 'checkbox',
},
+ {
+ 'key' => 'username-slash',
+ 'section' => 'username',
+ 'description' => 'Allow the slash character (/) in usernames. When using, make sure to set "Home directory" to fixed and blank in all svc_acct service definitions.',
+ 'type' => 'checkbox',
+ },
+
+ {
+ 'key' => 'username-equals',
+ 'section' => 'username',
+ 'description' => 'Allow the equal sign character (=) in usernames.',
+ 'type' => 'checkbox',
+ },
+
{
'key' => 'safe-part_bill_event',
'section' => 'UI',
@@ -1762,6 +1802,7 @@ and customer address. Include units.',
'select_hash' => [
'svc_acct' => 'Account (svc_acct)',
'svc_phone' => 'Phone number (svc_phone)',
+ 'svc_pbx' => 'PBX (svc_pbx)',
],
},
@@ -2220,6 +2261,32 @@ and customer address. Include units.',
},
{
+ 'key' => 'selfservice-agent_signup',
+ 'section' => 'self-service',
+ 'description' => 'Allow agent signup via self-service.',
+ 'type' => 'checkbox',
+ },
+
+ {
+ 'key' => 'selfservice-agent_signup-agent_type',
+ 'section' => 'self-service',
+ 'description' => 'Agent type when allowing agent signup via self-service.',
+ 'type' => 'select-sub',
+ 'options_sub' => sub { require FS::Record;
+ require FS::agent_type;
+ map { $_->typenum => $_->atype }
+ FS::Record::qsearch('agent_type', {} ); # disabled=>'' } );
+ },
+ 'option_sub' => sub { require FS::Record;
+ require FS::agent_type;
+ my $agent = FS::Record::qsearchs(
+ 'agent_type', { 'typenum'=>shift }
+ );
+ $agent_type ? $agent_type->atype : '';
+ },
+ },
+
+ {
'key' => 'card_refund-days',
'section' => 'billing',
'description' => 'After a payment, the number of days a refund link will be available for that payment. Defaults to 120.',
@@ -2252,7 +2319,15 @@ and customer address. Include units.',
{
'key' => 'global_unique-pbx_title',
'section' => '',
- 'description' => 'Global phone number uniqueness control: enabled (usual setting - svc_pbx.title must be unique), or disabled turns off duplicate checking for this field.',
+ 'description' => 'Global phone number uniqueness control: none (check uniqueness per exports), enabled (check across all services), or disabled (no duplicate checking).',
+ 'type' => 'select',
+ 'select_enum' => [ 'enabled', 'disabled' ],
+ },
+
+ {
+ 'key' => 'global_unique-pbx_id',
+ 'section' => '',
+ 'description' => 'Global PBX id uniqueness control: none (check uniqueness per exports), enabled (check across all services), or disabled (no duplicate checking).',
'type' => 'select',
'select_enum' => [ 'enabled', 'disabled' ],
},
@@ -2424,7 +2499,7 @@ and customer address. Include units.',
{
'key' => 'address1-search',
'section' => 'UI',
- 'description' => 'Enable the ability to search the address1 field from customer search.',
+ 'description' => 'Enable the ability to search the address1 field from the quick customer search. Not recommended in most cases as it tends to bring up too many search results - use explicit address searching from the advanced customer search instead.',
'type' => 'checkbox',
},
@@ -2579,6 +2654,13 @@ and customer address. Include units.',
},
{
+ 'key' => 'cust_pkg-large_pkg_size',
+ 'section' => 'UI',
+ 'description' => "In customer view, summarize packages with more than this many services. Set to zero to never summarize packages.",
+ 'type' => 'text',
+ },
+
+ {
'key' => 'svc_acct-edit_uid',
'section' => 'shell',
'description' => 'Allow UID editing.',
@@ -2623,7 +2705,7 @@ and customer address. Include units.',
{
'key' => 'voip-cdr_email',
'section' => '',
- 'description' => 'Include the call details on emailed invoices even if the customer is configured for not printing them on the invoices.',
+ 'description' => 'Include the call details on emailed invoices (and HTML invoices viewed in the backend), even if the customer is configured for not printing them on the invoices.',
'type' => 'checkbox',
},
@@ -2665,7 +2747,7 @@ and customer address. Include units.',
{
'key' => 'tax-pkg_address',
'section' => 'billing',
- 'description' => 'By default, tax calculations are done based on the billing address. Enable this switch to calculate tax based on the package address instead (when present). Note that this option is currently incompatible with vendor data taxation enabled by enable_taxproducts.',
+ 'description' => 'By default, tax calculations are done based on the billing address. Enable this switch to calculate tax based on the package address instead (when present).',
'type' => 'checkbox',
},
@@ -3111,6 +3193,26 @@ and customer address. Include units.',
},
{
+ 'key' => 'prepayment_discounts-credit_type',
+ 'section' => 'billing',
+ 'description' => 'Enables the offering of prepayment discounts and establishes the credit reason type.',
+ 'type' => 'select-sub',
+ 'options_sub' => sub { require FS::Record;
+ require FS::reason_type;
+ map { $_->typenum => $_->type }
+ FS::Record::qsearch('reason_type', { class=>'R' } );
+ },
+ 'option_sub' => sub { require FS::Record;
+ require FS::reason_type;
+ my $reason_type = FS::Record::qsearchs(
+ 'reason_type', { 'typenum' => shift }
+ );
+ $reason_type ? $reason_type->type : '';
+ },
+
+ },
+
+ {
'key' => 'cust_main-agent_custid-format',
'section' => '',
'description' => 'Enables searching of various formatted values in cust_main.agent_custid',
@@ -3199,7 +3301,7 @@ and customer address. Include units.',
'section' => 'UI',
'description' => 'The year to use in census tract lookups',
'type' => 'select',
- 'select_enum' => [ qw( 2009 2008 2007 2006 ) ],
+ 'select_enum' => [ qw( 2010 2009 2008 ) ],
},
{
@@ -3245,6 +3347,13 @@ and customer address. Include units.',
},
{
+ 'key' => 'cust_main-title-display_custnum',
+ 'section' => 'UI',
+ 'description' => 'Add the display_custom (agent_custid or custnum) to the title on customer view pages.',
+ 'type' => 'checkbox',
+ },
+
+ {
'key' => 'cust_bill-default_agent_invid',
'section' => 'UI',
'description' => 'Display the agent_invid field when available instead of the invnum field.',
@@ -3269,6 +3378,13 @@ and customer address. Include units.',
},
{
+ 'key' => 'order_pkg-no_start_date',
+ 'section' => 'UI',
+ 'description' => 'Don\'t set a default start date for new packages.',
+ 'type' => 'checkbox',
+ },
+
+ {
'key' => 'mcp_svcpart',
'section' => '',
'description' => 'Master Control Program svcpart. Leave this blank.',
@@ -3891,6 +4007,26 @@ and customer address. Include units.',
'type' => 'checkbox',
},
+ {
+ 'key' => 'cust_main-custom_link',
+ 'section' => 'UI',
+ 'description' => 'URL to use as source for the "Custom" tab in the View Customer page. The custnum will be appended.',
+ 'type' => 'text',
+ },
+
+ {
+ 'key' => 'cust_main-custom_title',
+ 'section' => 'UI',
+ 'description' => 'Title for the "Custom" tab in the View Customer page.',
+ 'type' => 'text',
+ },
+
+ {
+ 'key' => 'part_pkg-default_suspend_bill',
+ 'section' => 'billing',
+ 'description' => 'Default the "Continue recurring billing while suspended" flag to on for new package definitions.',
+ 'type' => 'checkbox',
+ },
{ key => "apacheroot", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
{ key => "apachemachine", section => "deprecated", description => "<b>DEPRECATED</b>", type => "text" },
diff --git a/FS/FS/ConfDefaults.pm b/FS/FS/ConfDefaults.pm
index 12c82e6..de65b44 100644
--- a/FS/FS/ConfDefaults.pm
+++ b/FS/FS/ConfDefaults.pm
@@ -49,22 +49,22 @@ sub cust_fields_avail { (
'custnum | Status | Last, First | Company | (same for service contact if present)',
'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Invoicing email(s)' =>
- 'custnum | Status | Last, First | Company | (all address fields) | Day phone | Night phone | Invoicing email(s)',
+ 'custnum | Status | Last, First | Company | (address) | Day phone | Night phone | Invoicing email(s)',
'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Fax number | Invoicing email(s) | Payment Type' =>
- 'custnum | Status | Last, First | Company | (all address fields) | (all phones) | Invoicing email(s) | Payment Type',
+ 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type',
'Cust# | Cust. Status | Name | Company | Address 1 | Address 2 | City | State | Zip | Country | Day phone | Night phone | Fax number | Invoicing email(s) | Payment Type | Current Balance' =>
- 'custnum | Status | Last, First | Company | (all address fields) | (all phones) | Invoicing email(s) | Payment Type | Current Balance',
+ 'custnum | Status | Last, First | Company | (address) | (all phones) | Invoicing email(s) | Payment Type | Current Balance',
'Cust# | Cust. Status | (bill) Name | (bill) Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Day phone | (bill) Night phone | (service) Name | (service) Company | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Day phone | (service) Night phone | Invoicing email(s)' =>
- 'custnum | Status | Last, First | Company | (all address fields) | Day phone | Night phone | (service address) | Invoicing email(s)',
+ 'custnum | Status | Last, First | Company | (address) | Day phone | Night phone | (service address) | Invoicing email(s)',
'Cust# | Cust. Status | (bill) Name | (bill) Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Day phone | (bill) Night phone | (bill) Fax number | (service) Name | (service) Company | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Day phone | (service) Night phone | (service) Fax number | Invoicing email(s) | Payment Type' =>
- 'custnum | Status | Last, First | Company | (all address fields) | (all phones) | (service address) | Invoicing email(s) | Payment Type',
+ 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type',
'Cust# | Cust. Status | (bill) Name | (bill) Company | (bill) Address 1 | (bill) Address 2 | (bill) City | (bill) State | (bill) Zip | (bill) Country | (bill) Day phone | (bill) Night phone | (bill) Fax number | (service) Name | (service) Company | (service) Address 1 | (service) Address 2 | (service) City | (service) State | (service) Zip | (service) Country | (service) Day phone | (service) Night phone | (service) Fax number | Invoicing email(s) | Payment Type | Current Balance' =>
- 'custnum | Status | Last, First | Company | (all address fields) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
+ 'custnum | Status | Last, First | Company | (address) | (all phones) | (service address) | Invoicing email(s) | Payment Type | Current Balance',
'Invoicing email(s)' => 'Invoicing email(s)',
'Cust# | Invoicing email(s)' => 'custnum | Invoicing email(s)',
diff --git a/FS/FS/Cron/alert_expiration.pm b/FS/FS/Cron/alert_expiration.pm
index 364fc60..eb53ea8 100644
--- a/FS/FS/Cron/alert_expiration.pm
+++ b/FS/FS/Cron/alert_expiration.pm
@@ -99,6 +99,7 @@ sub alert_expiration {
my $msgnum = $conf->config('alerter_msgnum', $agentnum);
if ( $msgnum ) { # new hotness
my $msg_template = qsearchs('msg_template', { msgnum => $msgnum } );
+ $customer->setfield('expdate', $expire_time);
$error = $msg_template->send('cust_main' => $customer);
}
else { #!$msgnum, the hard way
diff --git a/FS/FS/Cron/backup.pm b/FS/FS/Cron/backup.pm
index 204069a..9d88261 100644
--- a/FS/FS/Cron/backup.pm
+++ b/FS/FS/Cron/backup.pm
@@ -3,6 +3,7 @@ package FS::Cron::backup;
use strict;
use vars qw( @ISA @EXPORT_OK );
use Exporter;
+use Date::Format;
use FS::UID qw(driver_name datasrc);
@ISA = qw( Exporter );
@@ -12,6 +13,7 @@ sub backup_scp {
my $conf = new FS::Conf;
my $dest = $conf->config('dump-scpdest');
if ( $dest ) {
+ $dest .= time2str('/%Y%m%d%H%M%S',time);
datasrc =~ /dbname=([\w\.]+)$/ or die "unparsable datasrc ". datasrc;
my $database = $1;
eval "use Net::SCP qw(scp);";
@@ -30,11 +32,11 @@ sub backup_scp {
recipient => $conf->config('dump-pgpid'),
);
chmod 0600, '/var/tmp/$database.gpg';
- scp("/var/tmp/$database.gpg", $dest);
+ scp("/var/tmp/$database.gpg", "$dest.gpg");
unlink "/var/tmp/$database.gpg" or die $!;
} else {
chmod 0600, '/var/tmp/$database.sql';
- scp("/var/tmp/$database.sql", $dest);
+ scp("/var/tmp/$database.sql", "$dest.sql");
}
unlink "/var/tmp/$database.sql" or die $!;
}
diff --git a/FS/FS/Cron/bill.pm b/FS/FS/Cron/bill.pm
index 62bb321..7388733 100644
--- a/FS/FS/Cron/bill.pm
+++ b/FS/FS/Cron/bill.pm
@@ -5,7 +5,7 @@ use vars qw( @ISA @EXPORT_OK );
use Exporter;
use Date::Parse;
use DBI 1.33; #The "clone" method was added in DBI 1.33.
-use FS::UID qw(dbh);
+use FS::UID qw( dbh driver_name );
use FS::Record qw( qsearch qsearchs );
use FS::queue;
use FS::cust_main;
@@ -56,14 +56,20 @@ sub bill {
my $cursor_dbh = dbh->clone;
- $cursor_dbh->do(
- "DECLARE cron_bill_cursor CURSOR FOR ".
- " SELECT custnum FROM cust_main WHERE ". bill_where( %opt )
- ) or die $cursor_dbh->errstr;
+ my $select = 'SELECT custnum FROM cust_main WHERE '. bill_where( %opt );
+
+ unless ( driver_name =~ /^mysql/ ) {
+ $cursor_dbh->do( "DECLARE cron_bill_cursor CURSOR FOR $select" )
+ or die $cursor_dbh->errstr;
+ }
while ( 1 ) {
- my $sth = $cursor_dbh->prepare('FETCH 100 FROM cron_bill_cursor'); #mysql?
+ my $sql = (driver_name =~ /^mysql/)
+ ? $select
+ : 'FETCH 100 FROM cron_bill_cursor';
+
+ my $sth = $cursor_dbh->prepare($sql);
$sth->execute or die $sth->errstr;
@@ -120,6 +126,8 @@ sub bill {
}
+ last if driver_name =~ /^mysql/;
+
}
$cursor_dbh->commit or die $cursor_dbh->errstr;
@@ -194,6 +202,7 @@ sub bill_where {
)
END
+ #some false laziness w/cust_main::Billing due_cust_event
my $where_event = join(' OR ', map {
my $eventtable = $_;
diff --git a/FS/FS/Cron/notify.pm b/FS/FS/Cron/notify.pm
index ece96fc..3d427b2 100644
--- a/FS/FS/Cron/notify.pm
+++ b/FS/FS/Cron/notify.pm
@@ -4,7 +4,7 @@ use strict;
use vars qw( @ISA @EXPORT_OK $DEBUG );
use Exporter;
use FS::UID qw( dbh driver_name );
-use FS::Record qw(qsearch);
+use FS::Record qw(qsearch qsearchs);
use FS::cust_main;
use FS::cust_pkg;
@@ -37,7 +37,7 @@ sub notify_flat_delay {
and 0 < ( select count(*) from part_pkg_option
where part_pkg.pkgpart = part_pkg_option.pkgpart
and part_pkg_option.optionname = 'recur_notify'
- and CAST( part_pkg_option.optionvalue AS INTEGER ) > 0
+ and CAST( part_pkg_option.optionvalue AS $integer ) > 0
and 0 <= ( $time
+ CAST( part_pkg_option.optionvalue AS $integer )
* 86400
@@ -64,7 +64,7 @@ END
0 = ( select count(*) from cust_pkg_option
where cust_pkg.pkgnum = cust_pkg_option.pkgnum
and cust_pkg_option.optionname = 'impending_recur_notification_sent'
- and CAST( cust_pkg_option.optionvalue AS INTEGER ) = 1
+ and CAST( cust_pkg_option.optionvalue AS $integer ) = 1
)
END
@@ -106,6 +106,8 @@ END
my $msgnum = $conf->config('impending_recur_msgnum',$cust_main->agentnum);
if ( $msgnum ) {
my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
+ $cust_main->setfield('packages', \\@packages);
+ $cust_main->setfield('recurdates', \\@recurdates);
$error = $msg_template->send('cust_main' => $cust_main);
}
else {
diff --git a/FS/FS/Maestro.pm b/FS/FS/Maestro.pm
index 0569368..84f278c 100644
--- a/FS/FS/Maestro.pm
+++ b/FS/FS/Maestro.pm
@@ -1,10 +1,15 @@
package FS::Maestro;
+use strict;
use Date::Format;
use FS::Conf;
use FS::Record qw( qsearchs );
use FS::cust_main;
+use FS::cust_pkg;
+use FS::part_svc;
+#i guess this is kind of deprecated in favor of service_status, but keeping it
+#around until they say they don't need it.
sub customer_status {
my( $custnum ) = shift; #@_;
my $svcnum = @_ ? shift : '';
@@ -19,120 +24,224 @@ sub customer_status {
or return { 'status' => 'E',
'error' => "custnum $custnum not found" };
- my( $svc_pbx, $good_till, $outbound_service ) = ( '', '', '' );
+ return service_status($svcnum) if $svcnum;
+
+ ###
+ # regular customer to maestro (single package)
+ ###
+
my %result = ();
- if ( $svcnum ) {
-
- ###
- # reseller scenario to maestro (customer w/ multiple packages)
- ###
- # find $svc_pbx
+ my @cust_pkg = $cust_main->cust_pkg;
- $svc_pbx = qsearchs({
- 'table' => 'svc_pbx',
- 'addl_from' => ' LEFT JOIN cust_svc USING ( svcnum ) '.
- ' LEFT JOIN cust_pkg USING ( pkgnum ) ',
- 'hashref' => { 'svcnum' => $svcnum },
- 'extra_sql' => " AND custnum = $custnum",
- })
- or return { 'status' => 'E',
- 'error' => "svcnum $svcnum not found" };
+ #things specific to the non-reseller scenario
- #status in the reseller scenario
+ $result{'status'} = substr($cust_main->ucfirst_status,0,1);
- my $cust_pkg = $svc_pbx->cust_svc->cust_pkg;
+ $result{'products'} =
+ [ map $_->pkgpart, grep !$_->get('cancel'), @cust_pkg ];
- $result{'status'} = substr($cust_pkg->ucfirst_status,0,1);
+ #find svc_pbx
- # find "outbound service" y/n
+ my @cust_svc = map $_->cust_svc, @cust_pkg;
- #XXX outbound service per-reseller ?
- #my @cust_pkg = $cust_main->cust_pkg;
- #
- #my $conf = new FS::Conf;
- #my %outbound_pkgs = map { $_=>1 } $conf->config('mc-outbound_packages');
- #my $outbound_service =
- # scalar( grep { $outbound_pkgs{ $_->pkgpart }
- # && !$_->get('cancel')
- # }
- # @cust_pkg
- # )
- # ? 1 : 0;
+ my @cust_svc_pbx =
+ grep { my($n,$l,$t) = $_->label; $t eq 'svc_pbx' }
+ @cust_svc;
- # find "good till" date/time stamp (this package)
+ if ( ! @cust_svc_pbx ) {
+ return { 'status' => 'E',
+ 'error' => "customer $custnum has no conference service" };
+ } elsif ( scalar(@cust_svc_pbx) > 1 ) {
+ return { 'status' => 'E',
+ 'error' =>
+ "customer $custnum has more than one conference".
+ " service (reseller?); specify a svcnum as a second argument",
+ };
+ }
- $good_till = time2str('%c', $cust_pkg->bill || time );
+ my $cust_svc_pbx = $cust_svc_pbx[0];
- } else {
+ my $svc_pbx = $cust_svc_pbx->svc_x;
- ###
- # regular customer to maestro (single package)
- ###
+ # find "outbound service" y/n
- my @cust_pkg = $cust_main->cust_pkg;
+ my $conf = new FS::Conf;
+ my %outbound_pkgs = map { $_=>1 } $conf->config('mc-outbound_packages');
+ $result{'outbound_service'} =
+ scalar( grep { $outbound_pkgs{ $_->pkgpart }
+ && !$_->get('cancel')
+ }
+ @cust_pkg
+ )
+ ? 1 : 0;
- #things specific to the non-reseller scenario
+ # find "good till" date/time stamp
- $result{'status'} = substr($cust_main->ucfirst_status,0,1);
+ my @active_cust_pkg =
+ sort { $a->bill <=> $b->bill }
+ grep { !$_->get('cancel') && $_->part_pkg->freq ne '0' }
+ @cust_pkg;
+ $result{'good_till'} = time2str('%c', $active_cust_pkg[0]->bill || time );
- $result{'products'} =
- [ map $_->pkgpart, grep !$_->get('cancel'), @cust_pkg ];
+ return {
+ 'name' => $cust_main->name,
+ 'email' => $cust_main->invoicing_list_emailonly_scalar,
+ #'agentnum' => $cust_main->agentnum,
+ #'agent' => $cust_main->agent->agent,
+ 'max_lines' => $svc_pbx ? $svc_pbx->max_extensions : '',
+ 'max_simultaneous' => $svc_pbx ? $svc_pbx->max_simultaneous : '',
+ %result,
+ };
- #find svc_pbx
+}
- my @cust_svc = map $_->cust_svc, @cust_pkg;
+sub service_status {
+ my $svcnum = shift;
- my @cust_svc_pbx =
- grep { my($n,$l,$t) = $_->label; $t eq 'svc_pbx' }
- @cust_svc;
+ my $svc_pbx = qsearchs({
+ 'table' => 'svc_pbx',
+ 'addl_from' => ' LEFT JOIN cust_svc USING ( svcnum ) '.
+ ' LEFT JOIN cust_pkg USING ( pkgnum ) ',
+ 'hashref' => { 'svcnum' => $svcnum },
+ #'extra_sql' => " AND custnum = $custnum",
+ })
+ or return { 'status' => 'E',
+ 'error' => "svcnum $svcnum not found" };
- if ( ! @cust_svc_pbx ) {
- return { 'status' => 'E',
- 'error' => "customer $custnum has no conference service" };
- } elsif ( scalar(@cust_svc_pbx) > 1 ) {
- return { 'status' => 'E',
- 'error' =>
- "customer $custnum has more than one conference".
- " service (reseller?); specify a svcnum as a second argument",
- };
- }
+ my $cust_pkg = $svc_pbx->cust_svc->cust_pkg;
+ my $cust_main = $cust_pkg->cust_main;
- my $cust_svc_pbx = $cust_svc_pbx[0];
+ my %result = ();
- $svc_pbx = $cust_svc_pbx->svc_x;
+ #status in the reseller scenario
+ $result{'status'} = substr($cust_pkg->ucfirst_status,0,1);
+
+ # find "outbound service" y/n
+ my @cust_pkg = $cust_main->cust_pkg;
+ #XXX what about outbound service per-reseller ?
+ my $conf = new FS::Conf;
+ my %outbound_pkgs = map { $_=>1 } $conf->config('mc-outbound_packages');
+ $result{'outbound_service'} =
+ scalar( grep { $outbound_pkgs{ $_->pkgpart }
+ && !$_->get('cancel')
+ }
+ @cust_pkg
+ )
+ ? 1 : 0;
+
+ # find "good till" date/time stamp (this package)
+ $result{'good_till'} = time2str('%c', $cust_pkg->bill || time );
- # find "outbound service" y/n
+ return {
+ 'custnum' => $cust_main->custnum,
+ 'name' => $cust_main->name,
+ 'email' => $cust_main->invoicing_list_emailonly_scalar,
+ #'agentnum' => $cust_main->agentnum,
+ #'agent' => $cust_main->agent->agent,
+ 'max_lines' => $svc_pbx->max_extensions,
+ 'max_simultaneous' => $svc_pbx->max_simultaneous,
+ %result,
+ };
- my $conf = new FS::Conf;
- my %outbound_pkgs = map { $_=>1 } $conf->config('mc-outbound_packages');
- $outbound_service =
- scalar( grep { $outbound_pkgs{ $_->pkgpart }
- && !$_->get('cancel')
- }
- @cust_pkg
- )
- ? 1 : 0;
+}
- # find "good till" date/time stamp
+#some false laziness w/ MyAccount order_pkg
+sub order_pkg {
+ my $opt = ref($_[0]) ? shift : { @_ };
- my @active_cust_pkg =
- sort { $a->bill <=> $b->bill }
- grep { !$_->get('cancel') && $_->part_pkg->freq ne '0' }
- @cust_pkg;
- $good_till = time2str('%c', $active_cust_pkg[0]->bill || time );
+ $opt->{'title'} = delete $opt->{'name'}
+ if !exists($opt->{'title'}) && exists($opt->{'name'});
+
+ my $custnum = $opt->{'custnum'};
+
+ my $curuser = $FS::CurrentUser::CurrentUser;
+
+ my $cust_main = qsearchs({
+ 'table' => 'cust_main',
+ 'hashref' => { 'custnum' => $custnum },
+ 'extra_sql' => ' AND '. $curuser->agentnums_sql,
+ })
+ or return { 'error' => "custnum $custnum not found" };
+
+ my $status = $cust_main->status;
+ #false laziness w/ClientAPI/Signup.pm
+
+ my $cust_pkg = new FS::cust_pkg ( {
+ 'custnum' => $custnum,
+ 'pkgpart' => $opt->{'pkgpart'},
+ } );
+ my $error = $cust_pkg->check;
+ return { 'error' => $error } if $error;
+
+ my @svc = ();
+ unless ( $opt->{'svcpart'} eq 'none' ) {
+
+ my $svcpart = '';
+ if ( $opt->{'svcpart'} =~ /^(\d+)$/ ) {
+ $svcpart = $1;
+ } else {
+ $svcpart = $cust_pkg->part_pkg->svcpart; #($svcdb);
+ }
+
+ my $part_svc = qsearchs('part_svc', { 'svcpart' => $svcpart } );
+ return { 'error' => "Unknown svcpart $svcpart" } unless $part_svc;
+
+ my $svcdb = $part_svc->svcdb;
+
+ my %fields = (
+ 'svc_acct' => [ qw( username domsvc _password sec_phrase popnum ) ],
+ 'svc_domain' => [ qw( domain ) ],
+ 'svc_phone' => [ qw( phonenum pin sip_password phone_name ) ],
+ 'svc_external' => [ qw( id title ) ],
+ 'svc_pbx' => [ qw( id title ) ],
+ );
+
+ my $svc_x = "FS::$svcdb"->new( {
+ 'svcpart' => $svcpart,
+ map { $_ => $opt->{$_} } @{$fields{$svcdb}}
+ } );
+
+ #snarf processing not necessary here (or probably at all, anymore)
+
+ my $y = $svc_x->setdefault; # arguably should be in new method
+ return { 'error' => $y } if $y && !ref($y);
+
+ $error = $svc_x->check;
+ return { 'error' => $error } if $error;
+
+ push @svc, $svc_x;
}
- return {
- 'name' => $cust_main->name,
- 'email' => $cust_main->invoicing_list_emailonly_scalar,
- 'max_lines' => $svc_pbx ? $svc_pbx->max_extensions : '',
- 'max_simultaneous' => $svc_pbx ? $svc_pbx->max_simultaneous : '',
- 'outbound_service' => $outbound_service,
- 'good_till' => $good_till,
- %result,
- };
+ use Tie::RefHash;
+ tie my %hash, 'Tie::RefHash';
+ %hash = ( $cust_pkg => \@svc );
+ #msgcat
+ $error = $cust_main->order_pkgs( \%hash, '', 'noexport' => 1 );
+ return { 'error' => $error } if $error;
+
+# currently they're using this in the reseller scenario, so don't
+# bill the package immediately
+# my $conf = new FS::Conf;
+# if ( $conf->exists('signup_server-realtime') ) {
+#
+# my $bill_error = _do_bop_realtime( $cust_main, $status );
+#
+# if ($bill_error) {
+# $cust_pkg->cancel('quiet'=>1);
+# return $bill_error;
+# } else {
+# $cust_pkg->reexport;
+# }
+#
+# } else {
+ $cust_pkg->reexport;
+# }
+
+ my $svcnum = $svc[0] ? $svc[0]->svcnum : '';
+
+ return { error=>'', pkgnum=>$cust_pkg->pkgnum, svcnum=>$svcnum };
}
diff --git a/FS/FS/Mason.pm b/FS/FS/Mason.pm
index 0f14150..550ea1a 100644
--- a/FS/FS/Mason.pm
+++ b/FS/FS/Mason.pm
@@ -3,6 +3,7 @@ package FS::Mason;
use strict;
use vars qw( @ISA @EXPORT_OK $addl_handler_use );
use Exporter;
+use Carp;
use File::Slurp qw( slurp );
use HTML::Mason 1.27; #http://www.masonhq.com/?ApacheModPerl2Redirect
use HTML::Mason::Interp;
@@ -111,6 +112,8 @@ if ( -e $addl_handler_use_file ) {
#selectlayers.html
use Locale::Country;
use Business::US::USPS::WebTools::AddressStandardization;
+ use LWP::UserAgent;
+ use Storable qw( nfreeze thaw );
use FS;
use FS::UID qw( getotaker dbh datasrc driver_name );
use FS::Record qw( qsearch qsearchs fields dbdef
@@ -123,7 +126,7 @@ if ( -e $addl_handler_use_file ) {
use FS::UI::Web::small_custview qw(small_custview);
use FS::UI::bytecount;
use FS::Msgcat qw(gettext geterror);
- use FS::Misc qw( send_email send_fax
+ use FS::Misc qw( send_email send_fax ocr_image
states_hash counties cities state_label
);
use FS::Misc::eps2png qw( eps2png );
@@ -139,12 +142,14 @@ if ( -e $addl_handler_use_file ) {
use FS::cust_bill_pay;
use FS::cust_credit;
use FS::cust_credit_bill;
- use FS::cust_main qw(smart_search);
+ use FS::cust_main;
+ use FS::cust_main::Search qw(smart_search);
use FS::cust_main::Import;
use FS::cust_main_county;
use FS::cust_location;
use FS::cust_pay;
use FS::cust_pkg;
+ use FS::cust_pkg::Import;
use FS::part_pkg_taxclass;
use FS::cust_pkg_reason;
use FS::cust_refund;
@@ -248,6 +253,8 @@ if ( -e $addl_handler_use_file ) {
use FS::rate_time_interval;
use FS::msg_template;
use FS::part_tag;
+ use FS::acct_snarf;
+ use FS::part_pkg_discount;
# Sammath Naur
if ( $FS::Mason::addl_handler_use ) {
@@ -359,6 +366,7 @@ if ( -e $addl_handler_use_file ) {
sub include {
use vars qw($m);
+ #carp #should just switch to <& &> syntax
$m->scomp(@_);
}
@@ -448,7 +456,7 @@ sub mason_interps {
RT::LoadConfig();
}
- # A hook supporting strange legacy ways people have added stuff on
+ # A hook supporting strange legacy ways people (well, SG) have added stuff on
my @addl_comp_root = ();
my $addl_comp_root_file = '%%%FREESIDE_CONF%%%/addl_comp_root.pl';
@@ -463,17 +471,20 @@ sub mason_interps {
}
}
+ my $fs_comp_root =
+ scalar(@addl_comp_root)
+ ? [
+ [ 'freeside'=>'%%%FREESIDE_DOCUMENT_ROOT%%%' ],
+ @addl_comp_root,
+ ]
+ : '%%%FREESIDE_DOCUMENT_ROOT%%%';
+
my %interp = (
request_class => $request_class,
data_dir => '%%%MASONDATA%%%',
error_mode => 'output',
error_format => 'html',
ignore_warnings_expr => '.',
- comp_root => [
- [ 'freeside'=>'%%%FREESIDE_DOCUMENT_ROOT%%%' ],
- [ 'rt' =>'%%%FREESIDE_DOCUMENT_ROOT%%%/rt' ],
- @addl_comp_root,
- ],
);
$interp{out_method} = $opt{outbuf} if $mode eq 'standalone' && $opt{outbuf};
@@ -490,6 +501,7 @@ sub mason_interps {
my $fs_interp = new HTML::Mason::Interp (
%interp,
+ comp_root => $fs_comp_root,
escape_flags => { 'js_string' => $js_string_sub,
'defang' => sub {
${$_[0]} = $html_defang->defang(${$_[0]});
@@ -502,6 +514,10 @@ sub mason_interps {
my $rt_interp = new HTML::Mason::Interp (
%interp,
+ comp_root => [
+ [ 'rt' => '%%%FREESIDE_DOCUMENT_ROOT%%%/rt' ],
+ [ 'freeside' => '%%%FREESIDE_DOCUMENT_ROOT%%%' ],
+ ],
escape_flags => { 'h' => \&RT::Interface::Web::EscapeUTF8,
'js_string' => $js_string_sub,
},
diff --git a/FS/FS/Mason/Request.pm b/FS/FS/Mason/Request.pm
index 8d66f4f..95c8027 100644
--- a/FS/FS/Mason/Request.pm
+++ b/FS/FS/Mason/Request.pm
@@ -37,9 +37,19 @@ sub freeside_setup {
my( $filename, $mode ) = @_;
- #warn "initializing for $filename\n";
+ if ( $filename =~ qr(/REST/\d+\.\d+/NoAuth/) ) {
- if ( $filename !~ /\/rt\/.*NoAuth/ ) { #not RT images/JS
+ package HTML::Mason::Commands; #?
+ use FS::UID qw( adminsuidsetup );
+
+ #need to log somebody in for the mail gw
+
+ ##old installs w/fs_selfs or selfserv??
+ #&adminsuidsetup('fs_selfservice');
+
+ &adminsuidsetup('fs_queue');
+
+ } else {
package HTML::Mason::Commands;
use vars qw( $cgi $p $fsurl );
@@ -62,19 +72,7 @@ sub freeside_setup {
die "unknown mode $mode";
}
- } elsif ( $filename =~ /\/rt\/REST\/.*NoAuth/ ) {
-
- package HTML::Mason::Commands; #?
- use FS::UID qw( adminsuidsetup );
-
- #need to log somebody in for the mail gw
-
- ##old installs w/fs_selfs or selfserv??
- #&adminsuidsetup('fs_selfservice');
-
- &adminsuidsetup('fs_queue');
-
- }
+ }
}
diff --git a/FS/FS/Misc.pm b/FS/FS/Misc.pm
index 3b0616a..fe8ac60 100644
--- a/FS/FS/Misc.pm
+++ b/FS/FS/Misc.pm
@@ -20,6 +20,7 @@ use Tie::IxHash;
pkg_freqs
generate_ps generate_pdf do_print
csv_from_fixed
+ ocr_image
);
$DEBUG = 0;
@@ -113,7 +114,7 @@ sub send_email {
# join("\n", map { " $_: ". $options{$_} } keys %options ). "\n"
}
- my $to = ref($options{to}) ? join(', ', @{ $options{to} } ) : $options{to};
+ my @to = ref($options{to}) ? @{ $options{to} } : ( $options{to} );
my @mimeargs = ();
my @mimeparts = ();
@@ -172,7 +173,7 @@ sub send_email {
my $message = MIME::Entity->build(
'From' => $options{'from'},
- 'To' => $to,
+ 'To' => join(', ', @to),
'Sender' => $options{'from'},
'Reply-To' => $options{'from'},
'Date' => time2str("%a, %d %b %Y %X %z", time),
@@ -232,8 +233,11 @@ sub send_email {
$transport = Email::Sender::Transport::SMTP->new( %smtp_opt );
}
+ push @to, $options{bcc} if defined($options{bcc});
local $@; # just in case
- eval { sendmail($message, { transport => $transport }) };
+ eval { sendmail($message, { transport => $transport,
+ from => $options{from},
+ to => \@to }) };
if(ref($@) and $@->isa('Email::Sender::Failure')) {
return ($@->code ? $@->code.' ' : '').$@->message
@@ -257,6 +261,10 @@ Sender address, required
Recipient address, required
+=item bcc
+
+Blind copy address, optional
+
=item subject
email subject, required
@@ -290,6 +298,7 @@ sub generate_email {
my %return = (
'from' => $args{'from'},
'to' => $args{'to'},
+ 'bcc' => $args{'bcc'},
'subject' => $args{'subject'},
);
@@ -842,6 +851,41 @@ sub csv_from_fixed {
'';
}
+=item ocr_image IMAGE_SCALAR
+
+Runs OCR on the provided image data and returns a list of text lines.
+
+=cut
+
+sub ocr_image {
+ my $logo_data = shift;
+
+ #XXX use conf dir location from Makefile
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ my $fh = new File::Temp(
+ TEMPLATE => 'bizcard.XXXXXXXX',
+ SUFFIX => '.png', #XXX assuming, but should handle jpg, gif, etc. too
+ DIR => $dir,
+ UNLINK => 0,
+ ) or die "can't open temp file: $!\n";
+
+ my $filename = $fh->filename;
+
+ print $fh $logo_data;
+ close $fh;
+
+ run( [qw(ocroscript recognize), $filename], '>'=>"$filename.hocr" )
+ or die "ocroscript recognize failed\n";
+
+ run( [qw(ocroscript hocr-to-text), "$filename.hocr"], '>pipe'=>\*OUT )
+ or die "ocroscript hocr-to-text failed\n";
+
+ my @lines = split(/\n/, <OUT> );
+
+ foreach (@lines) { s/\.c0m\s*$/.com/; }
+
+ @lines;
+}
=back
diff --git a/FS/FS/Record.pm b/FS/FS/Record.pm
index cd5e2d4..92f503f 100644
--- a/FS/FS/Record.pm
+++ b/FS/FS/Record.pm
@@ -350,7 +350,8 @@ sub qsearch {
my @bind_type = ();
my $dbh = dbh;
foreach my $stable ( @stable ) {
- my $record = shift @record;
+ #stop altering the caller's hashref
+ my $record = { %{ shift(@record) || {} } };#and be liberal in receipt
my $select = shift @select;
my $extra_sql = shift @extra_sql;
my $extra_param = shift @extra_param;
@@ -1611,6 +1612,8 @@ Class method for batch imports. Available params:
=item table
+=item format - usual way to specify import, with this format string selecting data from the formats and format_* info hashes
+
=item formats
=item format_types
@@ -1623,6 +1626,10 @@ Class method for batch imports. Available params:
=item format_row_callbacks
+=item fields - Alternate way to specify import, specifying import fields directly as a listref
+
+=item postinsert_callback
+
=item params
=item job
@@ -1635,8 +1642,6 @@ FS::queue object, will be updated with progress
csv, xls or fixedlength
-=item format
-
=item empty_ok
=back
@@ -1647,21 +1652,64 @@ sub batch_import {
my $param = shift;
warn "$me batch_import call with params: \n". Dumper($param)
- ;# if $DEBUG;
+ if $DEBUG;
my $table = $param->{table};
- my $formats = $param->{formats};
my $job = $param->{job};
my $file = $param->{file};
- my $format = $param->{'format'};
my $params = $param->{params} || {};
- die "unknown format $format" unless exists $formats->{ $format };
+ my( $type, $header, $sep_char, $fixedlength_format, $row_callback, @fields );
+ my $postinsert_callback = '';
+ if ( $param->{'format'} ) {
+
+ my $format = $param->{'format'};
+ my $formats = $param->{formats};
+ die "unknown format $format" unless exists $formats->{ $format };
+
+ $type = $param->{'format_types'}
+ ? $param->{'format_types'}{ $format }
+ : $param->{type} || 'csv';
+
+
+ $header = $param->{'format_headers'}
+ ? $param->{'format_headers'}{ $param->{'format'} }
+ : 0;
+
+ $sep_char = $param->{'format_sep_chars'}
+ ? $param->{'format_sep_chars'}{ $param->{'format'} }
+ : ',';
- my $type = $param->{'format_types'}
- ? $param->{'format_types'}{ $format }
- : $param->{type} || 'csv';
+ $fixedlength_format =
+ $param->{'format_fixedlength_formats'}
+ ? $param->{'format_fixedlength_formats'}{ $param->{'format'} }
+ : '';
+
+ $row_callback =
+ $param->{'format_row_callbacks'}
+ ? $param->{'format_row_callbacks'}{ $param->{'format'} }
+ : '';
+
+ @fields = @{ $formats->{ $format } };
+
+ } elsif ( $param->{'fields'} ) {
+
+ $type = ''; #infer from filename
+ $header = 0;
+ $sep_char = ',';
+ $fixedlength_format = '';
+ $row_callback = '';
+ @fields = @{ $param->{'fields'} };
+
+ $postinsert_callback = $param->{'postinsert_callback'}
+ if $param->{'postinsert_callback'}
+
+ } else {
+ die "neither format nor fields specified";
+ }
+
+ #my $file = $param->{file};
unless ( $type ) {
if ( $file =~ /\.(\w+)$/i ) {
@@ -1675,25 +1723,6 @@ sub batch_import {
if $param->{'default_csv'} && $type ne 'xls';
}
- my $header = $param->{'format_headers'}
- ? $param->{'format_headers'}{ $param->{'format'} }
- : 0;
-
- my $sep_char = $param->{'format_sep_chars'}
- ? $param->{'format_sep_chars'}{ $param->{'format'} }
- : ',';
-
- my $fixedlength_format =
- $param->{'format_fixedlength_formats'}
- ? $param->{'format_fixedlength_formats'}{ $param->{'format'} }
- : '';
-
- my $row_callback =
- $param->{'format_row_callbacks'}
- ? $param->{'format_row_callbacks'}{ $param->{'format'} }
- : '';
-
- my @fields = @{ $formats->{ $format } };
my $row = 0;
my $count;
@@ -1757,6 +1786,7 @@ sub batch_import {
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
+ #my $params = $param->{params} || {};
if ( $param->{'batch_namecol'} && $param->{'batch_namevalue'} ) {
my $batch_col = $param->{'batch_keycol'};
@@ -1774,7 +1804,8 @@ sub batch_import {
$params->{ $batch_col } = $batch_value;
}
-
+
+ #my $job = $param->{job};
my $line;
my $imported = 0;
my( $last, $min_sec ) = ( time, 5 ); #progressbar foo
@@ -1832,6 +1863,7 @@ sub batch_import {
}
+ #my $table = $param->{table};
my $class = "FS::$table";
my $record = $class->new( \%hash );
@@ -1840,7 +1872,13 @@ sub batch_import {
while ( scalar(@later) ) {
my $sub = shift @later;
my $data = shift @later;
- &{$sub}($record, $data, $conf, $param); # $record->&{$sub}($data, $conf);
+ eval {
+ &{$sub}($record, $data, $conf, $param); # $record->&{$sub}($data, $conf)
+ };
+ if ( $@ ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't insert record". ( $line ? " for $line" : '' ). ": $@";
+ }
last if exists( $param->{skiprow} );
}
next if exists( $param->{skiprow} );
@@ -1855,6 +1893,15 @@ sub batch_import {
$row++;
$imported++;
+ if ( $postinsert_callback ) {
+ my $error = &{$postinsert_callback}($record, $param);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "postinsert_callback error". ( $line ? " for $line" : '' ).
+ ": $error";
+ }
+ }
+
if ( $job && time - $min_sec > $last ) { #progress bar
$job->update_statustext( int(100 * $imported / $count) );
$last = time;
@@ -1862,9 +1909,12 @@ sub batch_import {
}
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
+ unless ( $imported || $param->{empty_ok} ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Empty file!";
+ }
- return "Empty file!" unless $imported || $param->{empty_ok};
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
''; #no error
@@ -2121,7 +2171,7 @@ sub ut_text {
#warn "notexist ". \&notexist. "\n";
#warn "AUTOLOAD ". \&AUTOLOAD. "\n";
$self->getfield($field)
- =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>]+)$/
+ =~ /^([µ_0123456789aAáÁàÀâÂåÅäÄãêæÆbBcCçÇdDðÐeEéÉèÈêÊëËfFgGhHiIíÍìÌîÎïÏjJkKlLmMnNñÑoOóÓòÒôÔöÖõÕøغpPqQrRsSßtTuUúÚùÙûÛüÜvVwWxXyYýÝÿzZþÞ \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]\<\>]+)$/
or return gettext('illegal_or_empty_text'). " $field: ".
$self->getfield($field);
$self->setfield($field,$1);
@@ -2138,11 +2188,8 @@ May be null. If there is an error, returns the error, otherwise returns false.
sub ut_textn {
my($self,$field)=@_;
- $self->getfield($field)
- =~ /^([\w \!\@\#\$\%\&\(\)\-\+\;\:\'\"\,\.\?\/\=\[\]]*)$/
- or return gettext('illegal_text'). " $field: ". $self->getfield($field);
- $self->setfield($field,$1);
- '';
+ return $self->setfield($field, '') if $self->getfield($field) =~ /^$/;
+ $self->ut_text($field);
}
=item ut_alpha COLUMN
@@ -2411,7 +2458,9 @@ May not be null.
sub ut_name {
my( $self, $field ) = @_;
- $self->getfield($field) =~ /^([\w \,\.\-\']+)$/
+# warn "ut_name allowed alphanumerics: +(sort grep /\w/, map { chr() } 0..255), "\n";
+ #$self->getfield($field) =~ /^([\w \,\.\-\']+)$/
+ $self->getfield($field) =~ /^([µ_0123456789aAáÁàÀâÂåÅäÄãêæÆbBcCçÇdDðÐeEéÉèÈêÊëËfFgGhHiIíÍìÌîÎïÏjJkKlLmMnNñÑoOóÓòÒôÔöÖõÕøغpPqQrRsSßtTuUúÚùÙûÛüÜvVwWxXyYýÝÿzZþÞ \,\.\-\']+)$/
or return gettext('illegal_name'). " $field: ". $self->getfield($field);
$self->setfield($field,$1);
'';
@@ -2801,23 +2850,25 @@ sub h_date {
$h ? $h->history_date : '';
}
-=item scalar_sql SQL
+=item scalar_sql SQL [ PLACEHOLDER, ... ]
+
+A class or object method. Executes the sql statement represented by SQL and
+returns a scalar representing the result: the first column of the first row.
-A class method with a propensity for becoming an instance method. This
-method executes the sql statement represented by SQL and returns a scalar
-representing the result. Don't ask for rows -- you get the first column
-of the first row. Don't give me bogus SQL or I'll die on you.
+Dies on bogus SQL. Returns an empty string if no row is returned.
-Returns an empty string in the event of no rows.
+Typically used for statments which return a single value such as "SELECT
+COUNT(*) FROM table WHERE something" OR "SELECT column FROM table WHERE key = ?"
=cut
sub scalar_sql {
- my($self, $sql ) = ( shift, shift );
+ my($self, $sql) = (shift, shift);
my $sth = dbh->prepare($sql) or die dbh->errstr;
- $sth->execute
+ $sth->execute(@_)
or die "Unexpected error executing statement $sql: ". $sth->errstr;
- $sth->fetchrow_arrayref->[0] || '';
+ my $scalar = $sth->fetchrow_arrayref->[0];
+ defined($scalar) ? $scalar : '';
}
=back
diff --git a/FS/FS/Schema.pm b/FS/FS/Schema.pm
index 60d2bce..1838036 100644
--- a/FS/FS/Schema.pm
+++ b/FS/FS/Schema.pm
@@ -570,6 +570,7 @@ sub tables_hashref {
'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, '', '',
@@ -767,6 +768,7 @@ sub tables_hashref {
'squelch_cdr','char', 'NULL', 1, '', '',
'cdr_termination_percentage', 'decimal', 'NULL', '', '', '',
'invoice_terms', 'varchar', 'NULL', $char_d, '', '',
+ 'credit_limit', @money_typen, '', '',
'archived', 'char', 'NULL', 1, '', '',
'email_csv_cdr', 'char', 'NULL', 1, '', '',
],
@@ -829,8 +831,8 @@ sub tables_hashref {
'last', 'varchar', '', $char_d, '', '',
# 'middle', 'varchar', 'NULL', $char_d, '', '',
'first', 'varchar', '', $char_d, '', '',
- 'title', 'varchar', '', $char_d, '', '', #eg Head Bottle Washer
- 'comment', 'varchar', '', $char_d, '', '',
+ 'title', 'varchar', 'NULL', $char_d, '', '', #eg Head Bottle Washer
+ 'comment', 'varchar', 'NULL', $char_d, '', '',
'disabled', 'char', 'NULL', 1, '', '',
],
'primary_key' => 'contactnum',
@@ -842,13 +844,13 @@ sub tables_hashref {
'contact_phone' => {
'columns' => [
- 'contactphonenum', 'serial', '', '', '', '',
- 'contactnum', 'int', '', '', '', '',
- 'phonetypenum', 'int', '', '', '', '',
- 'countrycode', 'varchar', '', 3, '', '',
- 'phonenum', 'varchar', '', 14, '', '',
- 'extension', 'varchar', '', 7, '', '',
- #?#'comment', 'varchar', '', $char_d, '', '',
+ 'contactphonenum', 'serial', '', '', '', '',
+ 'contactnum', 'int', '', '', '', '',
+ 'phonetypenum', 'int', '', '', '', '',
+ 'countrycode', 'varchar', '', 3, '', '',
+ 'phonenum', 'varchar', '', 14, '', '',
+ 'extension', 'varchar', 'NULL', 7, '', '',
+ #?#'comment', 'varchar', '', $char_d, '', '',
],
'primary_key' => 'contactphonenum',
'unique' => [],
@@ -1192,10 +1194,11 @@ sub tables_hashref {
'reason', 'varchar', 'NULL', $char_d, '', '',
'otaker', 'varchar', 'NULL', 32, '', '',
'usernum', 'int', 'NULL', '', '', '',
+ 'void_usernum', 'int', 'NULL', '', '', '',
],
'primary_key' => 'paynum',
'unique' => [],
- 'index' => [ [ 'custnum' ], [ 'usernum' ], ],
+ 'index' => [ [ 'custnum' ], [ 'usernum' ], [ 'void_usernum' ] ],
},
'cust_bill_pay' => {
@@ -1289,6 +1292,7 @@ sub tables_hashref {
'pkgnum', 'serial', '', '', '', '',
'custnum', 'int', '', '', '', '',
'pkgpart', 'int', '', '', '', '',
+ 'pkgbatch', 'varchar', 'NULL', $char_d, '', '',
'locationnum', 'int', 'NULL', '', '', '',
'otaker', 'varchar', 'NULL', 32, '', '',
'usernum', 'int', 'NULL', '', '', '',
@@ -1300,6 +1304,7 @@ sub tables_hashref {
'adjourn', @date_type, '', '',
'cancel', @date_type, '', '',
'expire', @date_type, '', '',
+ 'contract_end', @date_type, '', '',
'change_date', @date_type, '', '',
'change_pkgnum', 'int', 'NULL', '', '', '',
'change_pkgpart', 'int', 'NULL', '', '', '',
@@ -1310,7 +1315,8 @@ sub tables_hashref {
],
'primary_key' => 'pkgnum',
'unique' => [],
- 'index' => [ ['custnum'], ['pkgpart'], [ 'locationnum' ], [ 'usernum' ],
+ 'index' => [ ['custnum'], ['pkgpart'], [ 'pkgbatch' ], [ 'locationnum' ],
+ [ 'usernum' ],
[ 'start_date' ], ['setup'], ['last_bill'], ['bill'],
['susp'], ['adjourn'], ['expire'], ['cancel'],
['change_date'],
@@ -1510,6 +1516,17 @@ sub tables_hashref {
# XXX somewhat borked unique: we don't really want a hidden and unhidden
# it turns out we'd prefer to use svc, bill, and invisibill (or something)
+ 'part_pkg_discount' => {
+ 'columns' => [
+ 'pkgdiscountnum', 'serial', '', '', '', '',
+ 'pkgpart', 'int', '', '', '', '',
+ 'discountnum', 'int', '', '', '', '',
+ ],
+ 'primary_key' => 'pkgdiscountnum',
+ 'unique' => [ [ 'pkgpart', 'discountnum' ] ],
+ 'index' => [],
+ },
+
'part_pkg_taxclass' => {
'columns' => [
'taxclassnum', 'serial', '', '', '', '',
@@ -1698,7 +1715,8 @@ sub tables_hashref {
'cgp_rpopallowed', 'char', 'NULL', 1, '', '', #RPOPAllowed
'cgp_mailtoall', 'char', 'NULL', 1, '', '', #MailToAll
'cgp_addmailtrailer', 'char', 'NULL', 1, '', '', #AddMailTrailer
- #XXX archive messages, mailing lists
+ 'cgp_archiveafter', 'int', 'NULL', '', '', '', #ArchiveMessagesAfter
+ #XXX mailing lists
#preferences
'cgp_deletemode', 'varchar', 'NULL', $char_d, '', '',#DeleteMode
'cgp_emptytrash', 'varchar', 'NULL', $char_d, '', '',#EmptyTrash
@@ -1708,7 +1726,6 @@ sub tables_hashref {
'cgp_prontoskinname', 'varchar', 'NULL', $char_d, '', '',#ProntoSkinName
'cgp_sendmdnmode', 'varchar', 'NULL', $char_d, '', '',#SendMDNMode
#mail
-#vacation message, redirect all mail, mail rules
#XXX RPOP settings
],
'primary_key' => 'svcnum',
@@ -1770,7 +1787,7 @@ sub tables_hashref {
'acct_def_cgp_rpopallowed', 'char', 'NULL', 1, '', '',
'acct_def_cgp_mailtoall', 'char', 'NULL', 1, '', '',
'acct_def_cgp_addmailtrailer', 'char', 'NULL', 1, '', '',
- #XXX archive messages
+ 'acct_def_cgp_archiveafter', 'int', 'NULL', '', '', '',
#preferences
'acct_def_cgp_deletemode', 'varchar', 'NULL', $char_d, '', '',
'acct_def_cgp_emptytrash', 'varchar', 'NULL', $char_d, '', '',
@@ -1779,8 +1796,6 @@ sub tables_hashref {
'acct_def_cgp_skinname', 'varchar', 'NULL', $char_d, '', '',
'acct_def_cgp_prontoskinname', 'varchar', 'NULL', $char_d, '', '',
'acct_def_cgp_sendmdnmode', 'varchar', 'NULL', $char_d, '', '',
- #mail
- #XXX rules, archive rule, spam foldering rule(s)
],
'primary_key' => 'svcnum',
'unique' => [ ],
@@ -1795,6 +1810,7 @@ sub tables_hashref {
'recaf', 'char', '', 2, '', '',
'rectype', 'varchar', '', 5, '', '',
'recdata', 'varchar', '', 255, '', '',
+ 'ttl', 'int', 'NULL', '', '', '',
],
'primary_key' => 'recnum',
'unique' => [],
@@ -2173,12 +2189,18 @@ sub tables_hashref {
'acct_snarf' => {
'columns' => [
- 'snarfnum', 'int', '', '', '', '',
- 'svcnum', 'int', '', '', '', '',
- 'machine', 'varchar', '', 255, '', '',
- 'protocol', 'varchar', '', $char_d, '', '',
- 'username', 'varchar', '', $char_d, '', '',
- '_password', 'varchar', '', $char_d, '', '',
+ 'snarfnum', 'serial', '', '', '', '',
+ 'snarfname', 'varchar', 'NULL', $char_d, '', '',
+ 'svcnum', 'int', '', '', '', '',
+ 'machine', 'varchar', '', 255, '', '',
+ 'protocol', 'varchar', '', $char_d, '', '',
+ 'username', 'varchar', '', $char_d, '', '',
+ '_password', 'varchar', '', $char_d, '', '',
+ 'check_freq', 'int', 'NULL', '', '', '',
+ 'leavemail', 'char', 'NULL', 1, '', '',
+ 'apop', 'char', 'NULL', 1, '', '',
+ 'tls', 'char', 'NULL', 1, '', '',
+ 'mailbox', 'varchar', 'NULL', $char_d, '', '',
],
'primary_key' => 'snarfnum',
'unique' => [],
@@ -2249,12 +2271,11 @@ sub tables_hashref {
'orig_regionnum', 'int', 'NULL', '', '', '',
'dest_regionnum', 'int', '', '', '', '',
'min_included', 'int', '', '', '', '',
- 'conn_charge', @money_type, '0', '', #'decimal','','10,5','0','',
+ 'conn_charge', 'decimal', '', '10,4', '0', '',
'conn_sec', 'int', '', '', '0', '',
'min_charge', 'decimal', '', '10,5', '', '', #@money_type, '', '',
'sec_granularity', 'int', '', '', '', '',
'ratetimenum', 'int', 'NULL', '', '', '',
- #time period (link to table of periods)?
'classnum', 'int', 'NULL', '', '', '',
],
'primary_key' => 'ratedetailnum',
@@ -2488,6 +2509,8 @@ sub tables_hashref {
'uniqueid', 'varchar', '', 32, \"''", '',
'userfield', 'varchar', '', 255, \"''", '',
+ 'max_callers', 'int', 'NULL', '', '', '',
+
###
# fields for unitel/RSLCOM/convergent that don't map well to asterisk
# defaults
@@ -2940,13 +2963,25 @@ sub tables_hashref {
'body', 'blob', 'NULL', '', '', '',
'disabled', 'char', 'NULL', 1, '', '',
'from_addr', 'varchar', 'NULL', 255, '', '',
+ 'bcc_addr', 'varchar', 'NULL', 255, '', '',
],
'primary_key' => 'msgnum',
'unique' => [ ['msgname', 'mime_type'] ],
'index' => [ ['agentnum'], ]
},
-
+ 'svc_cert' => {
+ 'columns' => [
+ 'svcnum', 'int', '', '', '', '',
+ 'recnum', 'int', '', '', '', '',
+ 'something', 'text', '', '', '', '',
+ #XXX more fields
+ ],
+ 'primary_key' => 'svcnum',
+ 'unique' => [],
+ 'index' => [], #recnum
+ },
+
# name type nullability length default local
diff --git a/FS/FS/Upgrade.pm b/FS/FS/Upgrade.pm
index b7a1c66..61a42a6 100644
--- a/FS/FS/Upgrade.pm
+++ b/FS/FS/Upgrade.pm
@@ -12,7 +12,7 @@ use FS::svc_domain;
$FS::svc_domain::whois_hack = 1;
@ISA = qw( Exporter );
-@EXPORT_OK = qw( upgrade upgrade_sqlradius );
+@EXPORT_OK = qw( upgrade_schema upgrade_config upgrade upgrade_sqlradius );
$DEBUG = 1;
@@ -33,7 +33,23 @@ database upgrades.
=over 4
-=item
+=item upgrade_config
+
+=cut
+
+#config upgrades
+sub upgrade_config {
+ my %opt = @_;
+
+ my $conf = new FS::Conf;
+
+ $conf->touch('payment_receipt')
+ if $conf->exists('payment_receipt_email')
+ || $conf->config('payment_receipt_msgnum');
+
+}
+
+=item upgrade
=cut
@@ -86,6 +102,9 @@ sub upgrade {
}
+=item upgrade_data
+
+=cut
sub upgrade_data {
my %opt = @_;
@@ -166,6 +185,67 @@ sub upgrade_data {
}
+=item upgrade_schema
+
+=cut
+
+sub upgrade_schema {
+ my %opt = @_;
+
+ my $data = upgrade_schema_data(%opt);
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ local $FS::UID::AutoCommit = 0;
+
+ foreach my $table ( keys %$data ) {
+
+ my $class = "FS::$table";
+ eval "use $class;";
+ die $@ if $@;
+
+ if ( $class->can('_upgrade_schema') ) {
+ warn "Upgrading $table schema...\n";
+
+ my $start = time;
+
+ $class->_upgrade_schema(%opt);
+
+ if ( $oldAutoCommit ) {
+ warn " committing\n";
+ dbh->commit or die dbh->errstr;
+ }
+
+ #warn "\e[1K\rUpgrading $table... done in ". (time-$start). " seconds\n";
+ warn " done in ". (time-$start). " seconds\n";
+
+ } else {
+ warn "WARNING: asked for schema upgrade of $table,".
+ " but FS::$table has no _upgrade_schema method\n";
+ }
+
+ }
+
+}
+
+=item upgrade_schema_data
+
+=cut
+
+sub upgrade_schema_data {
+ my %opt = @_;
+
+ tie my %hash, 'Tie::IxHash',
+
+ #fix classnum character(1)
+ 'cust_bill_pkg_detail' => [],
+
+ ;
+
+ \%hash;
+
+}
+
sub upgrade_sqlradius {
#my %opt = @_;
diff --git a/FS/FS/access_right.pm b/FS/FS/access_right.pm
index bc6dd5d..ef8cc6c 100644
--- a/FS/FS/access_right.pm
+++ b/FS/FS/access_right.pm
@@ -145,6 +145,41 @@ sub _upgrade_data { # class method
if $error;
}
+ my %migrate = (
+ 'Post payment' => [ 'Post check payment', 'Post cash payment' ],
+ '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' ],
+ );
+
+ foreach my $oldright (keys %migrate) {
+ my @old = qsearch('access_right', { 'righttype'=>'FS::access_group',
+ 'rightname'=>$oldright,
+ }
+ );
+
+ foreach my $old ( @old ) {
+
+ foreach my $newright ( @{ $migrate{$oldright} } ) {
+ my %hash = (
+ 'righttype' => 'FS::access_group',
+ 'rightobjnum' => $old->rightobjnum,
+ 'rightname' => $newright,
+ );
+ next if qsearchs('access_right', \%hash);
+ my $access_right = new FS::access_right \%hash;
+ my $error = $access_right->insert;
+ die $error if $error;
+ }
+
+ #after the WEST stuff is sorted, etc.
+ #my $error = $old->delete;
+ #die $error if $error;
+
+ }
+
+ }
+
'';
}
diff --git a/FS/FS/access_user.pm b/FS/FS/access_user.pm
index 8c8ba8b..075733a 100644
--- a/FS/FS/access_user.pm
+++ b/FS/FS/access_user.pm
@@ -132,9 +132,8 @@ sub insert {
sub htpasswd_kludge {
my $self = shift;
-
- #awful kludge to skip setting htpasswd for fs_* users
- return '' if $self->username =~ /^fs_/;
+
+ return '' if $self->is_system_user;
unshift @_, '-c' unless -e $htpasswd_file;
if (
@@ -270,7 +269,9 @@ Returns a name string for this user: "Last, First".
sub name {
my $self = shift;
- $self->get('last'). ', '. $self->first;
+ return $self->username
+ if $self->get('last') eq 'Lastname' && $self->first eq 'Firstname';
+ return $self->get('last'). ', '. $self->first;
}
=item user_cust_main
@@ -510,6 +511,25 @@ sub default_customer_view {
}
+=item is_system_user
+
+Returns true if this user has the name of a known system account. These
+users will not appear in the htpasswd file and can't have passwords set.
+
+=cut
+
+sub is_system_user {
+ my $self = shift;
+ return grep { $_ eq $self->username } ( qw(
+ fs_queue
+ fs_daily
+ fs_selfservice
+ fs_signup
+ fs_bootstrap
+ fs_selfserv
+) );
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/acct_snarf.pm b/FS/FS/acct_snarf.pm
index b4e88bf..9816de9 100644
--- a/FS/FS/acct_snarf.pm
+++ b/FS/FS/acct_snarf.pm
@@ -2,7 +2,9 @@ package FS::acct_snarf;
use strict;
use vars qw( @ISA );
-use FS::Record;
+use Tie::IxHash;
+use FS::Record qw( qsearchs );
+use FS::cust_svc;
@ISA = qw( FS::Record );
@@ -35,6 +37,8 @@ fields are currently supported:
=item snarfnum - primary key
+=item snarfname - Label
+
=item svcnum - Account (see L<FS::svc_acct>)
=item machine - external machine to download mail from
@@ -88,6 +92,37 @@ returns the error, otherwise returns false.
# the replace method can be inherited from FS::Record
+=item cust_svc
+
+=cut
+
+sub cust_svc {
+ my $self = shift;
+ qsearchs('cust_svc', { 'svcnum' => $self->svcnum } );
+}
+
+
+=item svc_export
+
+Calls the replace export for any communigate exports attached to this rule's
+service.
+
+=cut
+
+sub svc_export {
+ my $self = shift;
+
+ my $cust_svc = $self->cust_svc;
+ my $svc_x = $cust_svc->svc_x;
+
+ #_singledomain too
+ my @exports = $cust_svc->part_svc->part_export('communigate_pro');
+ my @errors = map $_->export_replace($svc_x, $svc_x), @exports;
+
+ @errors ? join(' / ', @errors) : '';
+
+}
+
=item check
Checks all fields to make sure this is a valid external mail account. If
@@ -100,11 +135,17 @@ sub check {
my $self = shift;
my $error =
$self->ut_numbern('snarfnum')
+ || $self->ut_textn('snarfname') #alphasn?
|| $self->ut_number('svcnum')
|| $self->ut_foreign_key('svcnum', 'svc_acct', 'svcnum')
|| $self->ut_domain('machine')
|| $self->ut_alphan('protocol')
|| $self->ut_textn('username')
+ || $self->ut_numbern('check_freq')
+ || $self->ut_enum('leavemail', [ '', 'Y' ])
+ || $self->ut_enum('apop', [ '', 'Y' ])
+ || $self->ut_enum('tls', [ '', 'Y' ])
+ || $self->ut_alphan('mailbox')
;
return $error if $error;
@@ -114,6 +155,52 @@ sub check {
''; #no error
}
+sub check_freq_labels {
+
+ tie my %hash, 'Tie::IxHash',
+ 0 => 'Never',
+ 60 => 'minute',
+ 120 => '2 minutes',
+ 180 => '3 minutes',
+ 300 => '5 minutes',
+ 600 => '10 minutes',
+ 900 => '15 minutes',
+ 1800 => '30 minutes',
+ 3600 => 'hour',
+ 7200 => '2 hours',
+ 10800 => '3 hours',
+ 21600 => '6 hours',
+ 43200 => '12 hours',
+ 86400 => 'day',
+ 172800 => '2 days',
+ 259200 => '3 days',
+ 604800 => 'week',
+ 1000000000 => 'Disabled',
+ ;
+
+ \%hash;
+}
+
+=item cgp_hashref
+
+Returns a hashref representing this external mail account, suitable for
+Communigate Pro API commands:
+
+=cut
+
+sub cgp_hashref {
+ my $self = shift;
+ {
+ 'authName' => $self->username,
+ 'domain' => $self->machine,
+ 'password' => $self->_password,
+ 'period' => $self->check_freq.'s',
+ 'APOP' => ( $self->apop eq 'Y' ? 'YES' : 'NO' ),
+ 'TLS' => ( $self->tls eq 'Y' ? 'YES' : 'NO' ),
+ 'Leave' => ( $self->leavemail eq 'Y' ? 'YES' : 'NO' ), #XXX leave??
+ };
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/agent_type.pm b/FS/FS/agent_type.pm
index 2660bb4..5d6b94e 100644
--- a/FS/FS/agent_type.pm
+++ b/FS/FS/agent_type.pm
@@ -2,7 +2,7 @@ package FS::agent_type;
use strict;
use vars qw( @ISA );
-use FS::Record qw( qsearch );
+use FS::Record qw( qsearch dbh );
use FS::m2m_Common;
use FS::agent;
use FS::type_pkgs;
@@ -119,9 +119,7 @@ L<FS::part_pkg>.
sub pkgpart_hashref {
my $self = shift;
my %pkgpart;
- #$pkgpart{$_}++ foreach $self->pkgpart;
- # not compatible w/5.004_04 (fixed in 5.004_05)
- foreach ( $self->pkgpart ) { $pkgpart{$_}++; }
+ $pkgpart{$_}++ foreach $self->pkgpart;
\%pkgpart;
}
@@ -166,7 +164,13 @@ agent type.
sub pkgpart {
my $self = shift;
- map $_->pkgpart, $self->type_pkgs;
+
+ #map $_->pkgpart, $self->type_pkgs;
+
+ my $sql = 'SELECT pkgpart FROM type_pkgs WHERE typenum = ?';
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute( $self->typenum ) or die $sth->errstr;
+ map $_->[0], @{ $sth->fetchall_arrayref };
}
=back
diff --git a/FS/FS/banned_pay.pm b/FS/FS/banned_pay.pm
index a862028..3379653 100644
--- a/FS/FS/banned_pay.pm
+++ b/FS/FS/banned_pay.pm
@@ -4,6 +4,7 @@ use strict;
use base qw( FS::otaker_Mixin FS::Record );
use FS::Record qw( qsearch qsearchs );
use FS::UID qw( getotaker );
+use FS::CurrentUser;
=head1 NAME
@@ -41,7 +42,7 @@ supported:
=item _date - specified as a UNIX timestamp; see L<perlfunc/"time">. Also see
L<Time::Local> and L<Date::Parse> for conversion functions.
-=item otaker - order taker (assigned automatically, see L<FS::UID>)
+=item usernum - order taker (assigned automatically, see L<FS::access_user>)
=item reason - reason (text)
@@ -115,7 +116,7 @@ sub check {
$self->_date(time) unless $self->_date;
- $self->otaker(getotaker) unless $self->otaker;
+ $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
$self->SUPER::check;
}
diff --git a/FS/FS/cdr.pm b/FS/FS/cdr.pm
index e8be080..8f6a43c 100644
--- a/FS/FS/cdr.pm
+++ b/FS/FS/cdr.pm
@@ -285,7 +285,7 @@ sub check {
# ;
# return $error if $error;
- for my $f ( grep { $self->$_ =~ /[a-z ]/i } qw( startdate enddate ) ) {
+ for my $f ( grep { $self->$_ =~ /\D/ } qw(startdate answerdate enddate)){
$self->$f( str2time($self->$f) );
}
@@ -548,14 +548,20 @@ sub export_formats {
my $conf = new FS::Conf;
my $date_format = $conf->config('date_format') || '%m/%d/%Y';
+ # This is now smarter, and shows the call duration in the
+ # largest units that accurately reflect the granularity.
my $duration_sub = sub {
my($cdr, %opt) = @_;
- if ( $opt{minutes} ) {
- $opt{minutes}. ( $opt{granularity} ? 'm' : ' call' );
- } else {
- #config if anyone really wants decimal minutes back
- #sprintf('%.2fm', $cdr->billsec / 60 );
- int($cdr->billsec / 60).'m '. ($cdr->billsec % 60).'s';
+ my $sec = $opt{seconds} || $cdr->billsec;
+ if ( length($opt{granularity}) &&
+ $opt{granularity} == 0 ) { #per call
+ return '1 call';
+ }
+ elsif ( $opt{granularity} == 60 ) {#full minutes
+ return sprintf("%.0fm",$sec/60);
+ }
+ else { #anything else
+ return sprintf("%dm %ds", $sec/60, $sec%60);
}
};
@@ -768,8 +774,11 @@ sub _cdr_date_parse {
if ( $date =~ /^\s*(\d{4})\D(\d{1,2})\D(\d{1,2})\D+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
- } elsif ( $date =~ /^\s*(\d{1,2})\D(\d{1,2})\D(\d{4})\s+(\d{1,2})\D(\d{1,2})\D(\d{1,2})(\D|$)/ ) {
+ } elsif ( $date =~ /^\s*(\d{1,2})\D(\d{1,2})\D(\d{4})\s+(\d{1,2})\D(\d{1,2})(?:\D(\d{1,2}))?(\D|$)/ ) {
+ # 8/26/2010 12:20:01
+ # optionally without seconds
($mon, $day, $year, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
+ $sec = 0 if !defined($sec);
} elsif ( $date =~ /^\s*(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d+\.\d+)(\D|$)/ ) {
# broadsoft: 20081223201938.314
($year, $mon, $day, $hour, $min, $sec) = ( $1, $2, $3, $4, $5, $6 );
diff --git a/FS/FS/cdr/cia.pm b/FS/FS/cdr/cia.pm
new file mode 100644
index 0000000..6134333
--- /dev/null
+++ b/FS/FS/cdr/cia.pm
@@ -0,0 +1,39 @@
+package FS::cdr::cia;
+
+use strict;
+use vars qw( @ISA %info );
+use FS::cdr qw(_cdr_date_parser_maker);
+
+@ISA = qw(FS::cdr);
+
+%info = (
+ 'name' => 'Client Instant Access',
+ 'weight' => 510,
+ 'header' => 1,
+ 'type' => 'csv',
+ 'sep_char' => "\t",
+ 'import_fields' => [
+ skip(2), # Reseller Account Number, Confirmation Number
+ 'description', # Conference Name
+ skip(3), # Organization Name, Bill Code, Q&A Active
+ 'userfield', # Chairperson Name
+ skip(2), # Conference Start Time, Conference End Time
+ _cdr_date_parser_maker('startdate'), # Connect Time
+ _cdr_date_parser_maker('enddate'), # Disconnect Time
+ sub { my($cdr, $data, $conf, $param) = @_;
+ $cdr->duration($data);
+ $cdr->billsec( $data);
+ }, # Duration
+ skip(2), # Roundup Duration, User Name
+ 'dst', # DNIS
+ 'src', # ANI
+ skip(2), # Call Type, Toll Free,
+ skip(1), # Chair Conference Entry Code
+ 'accountcode', # Participant Conference Entry Code,
+ ],
+
+);
+
+sub skip { map {''} (1..$_[0]) }
+
+1;
diff --git a/FS/FS/cdr/infinite.pm b/FS/FS/cdr/infinite.pm
new file mode 100644
index 0000000..90560c8
--- /dev/null
+++ b/FS/FS/cdr/infinite.pm
@@ -0,0 +1,41 @@
+package FS::cdr::infinite;
+
+use strict;
+use vars qw( @ISA %info );
+use FS::cdr qw(_cdr_date_parser_maker);
+
+@ISA = qw(FS::cdr);
+
+%info = (
+ 'name' => 'Infinite Conferencing',
+ 'weight' => 520,
+ 'header' => 1,
+ 'type' => 'csv',
+ 'sep_char' => ',',
+ 'import_fields' => [
+ 'uniqueid', # billid
+ skip(3), # confid, invoicenum, acctgrpid
+ 'accountcode', # accountid ("Room Confirmation Number")
+ skip(2), # billingcode ("Room Billingcode"), confname
+ skip(1), # participant_type
+ 'startdate', # starttime_t
+ skip(2), # startdate, starttime
+ sub { my($cdr, $data, $conf, $param) = @_;
+ $cdr->duration($data * 60);
+ $cdr->billsec( $data * 60);
+ }, # minutes
+ 'dst', # dnis
+ 'src', # ani
+ skip(8), # calltype, calltype_text, confstart_t, confstartdate,
+ # confstarttime, confminutes, conflegs, ppm
+ 'upstream_price', # callcost
+ skip(13), # confcost, rppm, rcallcost, rconfcost,
+ # auxdata[1..4], ldval, sysname, username, cec, pec
+ 'userfield', # unnamed field
+ ],
+
+);
+
+sub skip { map {''} (1..$_[0]) }
+
+1;
diff --git a/FS/FS/contact.pm b/FS/FS/contact.pm
index d3ab411..774aed0 100644
--- a/FS/FS/contact.pm
+++ b/FS/FS/contact.pm
@@ -2,10 +2,12 @@ package FS::contact;
use strict;
use base qw( FS::Record );
-use FS::Record qw( qsearch qsearchs );
+use FS::Record qw( qsearch qsearchs dbh );
use FS::prospect_main;
use FS::cust_main;
use FS::cust_location;
+use FS::contact_phone;
+use FS::contact_email;
=head1 NAME
@@ -96,7 +98,59 @@ otherwise returns false.
=cut
-# the insert method can be inherited from FS::Record
+sub insert {
+ my $self = shift;
+
+ 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;
+ }
+
+ foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) =~ /\S/ }
+ keys %{ $self->hashref } ) {
+ $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
+ my $phonetypenum = $1;
+
+ my $contact_phone = new FS::contact_phone {
+ 'contactnum' => $self->contactnum,
+ 'phonetypenum' => $phonetypenum,
+ _parse_phonestring( $self->get($pf) ),
+ };
+ $error = $contact_phone->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ if ( $self->get('emailaddress') =~ /\S/ ) {
+ my $contact_email = new FS::contact_email {
+ 'contactnum' => $self->contactnum,
+ 'emailaddress' => $self->get('emailaddress'),
+ };
+ $error = $contact_email->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ '';
+
+}
=item delete
@@ -106,6 +160,8 @@ Delete this record from the database.
# the delete method can be inherited from FS::Record
+# XXX delete contact_phone, contact_email
+
=item replace OLD_RECORD
Replaces the OLD_RECORD with this one in the database. If there is an error,
@@ -113,7 +169,76 @@ returns the error, otherwise returns false.
=cut
-# the replace method can be inherited from FS::Record
+sub replace {
+ my $self = shift;
+
+ 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;
+ }
+
+ foreach my $pf ( grep { /^phonetypenum(\d+)$/ && $self->get($_) }
+ keys %{ $self->hashref } ) {
+ $pf =~ /^phonetypenum(\d+)$/ or die "wtf (daily, the)";
+ my $phonetypenum = $1;
+
+ my %cp = ( 'contactnum' => $self->contactnum,
+ 'phonetypenum' => $phonetypenum,
+ );
+ my $contact_phone = qsearchs('contact_phone', \%cp)
+ || new FS::contact_phone \%cp;
+
+ my %cpd = _parse_phonestring( $self->get($pf) );
+ $contact_phone->set( $_ => $cpd{$_} ) foreach keys %cpd;
+
+ my $method = $contact_phone->contactphonenum ? 'replace' : 'insert';
+
+ $error = $contact_phone->$method;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ '';
+
+}
+
+#i probably belong in contact_phone.pm
+sub _parse_phonestring {
+ my $value = shift;
+
+ my($countrycode, $extension) = ('1', '');
+
+ #countrycode
+ if ( $value =~ s/^\s*\+\s*(\d+)// ) {
+ $countrycode = $1;
+ } else {
+ $value =~ s/^\s*1//;
+ }
+ #extension
+ if ( $value =~ s/\s*(ext|x)\s*(\d+)\s*$//i ) {
+ $extension = $2;
+ }
+
+ ( 'countrycode' => $countrycode,
+ 'phonenum' => $value,
+ 'extension' => $extension,
+ );
+}
=item check
@@ -165,8 +290,6 @@ sub line {
=head1 BUGS
-The author forgot to customize this manpage.
-
=head1 SEE ALSO
L<FS::Record>, schema.html from the base documentation.
diff --git a/FS/FS/contact_phone.pm b/FS/FS/contact_phone.pm
index bb9cf03..ad8e8f7 100644
--- a/FS/FS/contact_phone.pm
+++ b/FS/FS/contact_phone.pm
@@ -120,7 +120,7 @@ sub check {
|| $self->ut_number('phonetypenum')
|| $self->ut_text('countrycode')
|| $self->ut_text('phonenum')
- || $self->ut_text('extension')
+ || $self->ut_textn('extension')
;
return $error if $error;
diff --git a/FS/FS/cust_bill.pm b/FS/FS/cust_bill.pm
index 4bd9aa1..335c4b6 100644
--- a/FS/FS/cust_bill.pm
+++ b/FS/FS/cust_bill.pm
@@ -162,6 +162,45 @@ sub cust_unlinked_msg {
Adds this invoice to the database ("Posts" the invoice). If there is an error,
returns the error, otherwise returns false.
+=cut
+
+sub insert {
+ my $self = shift;
+ warn "$me insert called\n" if $DEBUG;
+
+ 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;
+ }
+
+ if ( $self->get('cust_bill_pkg') ) {
+ foreach my $cust_bill_pkg ( @{$self->get('cust_bill_pkg')} ) {
+ $cust_bill_pkg->invnum($self->invnum);
+ my $error = $cust_bill_pkg->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't create invoice line item: $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
@@ -224,13 +263,13 @@ sub delete {
}
-=item replace OLD_RECORD
+=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.
+You can, but probably shouldn't modify invoices...
-Only printed may be changed. printed is normally updated by calling the
-collect method of a customer object (see L<FS::cust_main>).
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record. If there is an error, returns the error,
+otherwise returns false.
=cut
@@ -241,11 +280,11 @@ collect method of a customer object (see L<FS::cust_main>).
sub replace_check {
my( $new, $old ) = ( shift, shift );
- return "Can't change custnum!" unless $old->custnum == $new->custnum;
+ return "Can't modify closed invoice" if $old->closed =~ /^Y/i;
#return "Can't change _date!" unless $old->_date eq $new->_date;
- return "Can't change _date!" unless $old->_date == $new->_date;
- return "Can't change charged!" unless $old->charged == $new->charged
- || $old->charged == 0;
+ return "Can't change _date" unless $old->_date == $new->_date;
+ return "Can't change charged" unless $old->charged == $new->charged
+ || $old->charged == 0;
'';
}
@@ -2252,10 +2291,12 @@ sub print_generic {
my $nbsp = $nbsps{$format};
my %escape_functions = ( 'latex' => \&_latex_escape,
- 'html' => \&encode_entities,
+ 'html' => \&_html_escape_nbsp,#\&encode_entities,
'template' => sub { shift },
);
my $escape_function = $escape_functions{$format};
+ my $escape_function_nonbsp = ($format eq 'html')
+ ? \&_html_escape : $escape_function;
my %date_formats = ( 'latex' => '%b %o, %Y',
'html' => '%b&nbsp;%o,&nbsp;%Y',
@@ -2386,7 +2427,8 @@ sub print_generic {
qsearchs('pkg_class', { classnum => $conf->config('finance_pkgclass') });
$invoice_data{finance_section} = $pkg_class->categoryname;
}
- $invoice_data{finance_amount} = '0.00';
+ $invoice_data{finance_amount} = '0.00';
+ $invoice_data{finance_section} ||= 'Finance Charges'; #avoid config confusion
my $countrydefault = $conf->config('countrydefault') || 'US';
my $prefix = $cust_main->has_ship_address ? 'ship_' : '';
@@ -2558,7 +2600,7 @@ sub print_generic {
my $extra_lines = ();
if ( $multisection ) {
($extra_sections, $extra_lines) =
- $self->_items_extra_usage_sections($escape_function, $format)
+ $self->_items_extra_usage_sections($escape_function_nonbsp, $format)
if $conf->exists('usage_class_as_a_section', $cust_main->agentnum);
push @$extra_sections, $adjust_section if $adjust_section->{sort_weight};
@@ -2567,13 +2609,13 @@ sub print_generic {
push @sections,
$self->_items_sections( $late_sections, # this could stand a refactor
$summarypage,
- $escape_function,
+ $escape_function_nonbsp,
$extra_sections,
$format, #bah
);
if ($conf->exists('svc_phone_sections')) {
my ($phone_sections, $phone_lines) =
- $self->_items_svc_phone_sections($escape_function, $format);
+ $self->_items_svc_phone_sections($escape_function_nonbsp, $format);
push @{$late_sections}, @$phone_sections;
push @detail_items, @$phone_lines;
}
@@ -2657,6 +2699,7 @@ sub print_generic {
$options{'skip_usage'} =
scalar(@$extra_sections) && !grep{$section == $_} @$extra_sections;
$options{'multilocation'} = $multilocation;
+ $options{'multisection'} = $multisection;
foreach my $line_item ( $self->_items_pkg(%options) ) {
my $detail = {
@@ -3039,6 +3082,7 @@ sub print_ps {
my ($file, $lfile) = $self->print_latex(@_);
my $ps = generate_ps($file);
+ unlink($file.'.tex');
unlink($lfile);
$ps;
@@ -3067,6 +3111,7 @@ sub print_pdf {
my ($file, $lfile) = $self->print_latex(@_);
my $pdf = generate_pdf($file);
+ unlink($file.'.tex');
unlink($lfile);
$pdf;
@@ -3122,6 +3167,18 @@ sub _latex_escape {
$value;
}
+sub _html_escape {
+ my $value = shift;
+ encode_entities($value);
+ $value;
+}
+
+sub _html_escape_nbsp {
+ my $value = _html_escape(shift);
+ $value =~ s/ +/&nbsp;/g;
+ $value;
+}
+
#utility methods for print_*
sub _translate_old_latex_format {
@@ -3356,6 +3413,7 @@ sub _items_sections {
if ( $summarypage ) {
@sections = grep { exists($subtotal{$_}) || ! _pkg_category($_)->disabled }
map { $_->categoryname } qsearch('pkg_category', {});
+ push @sections, '' if exists($subtotal{''});
} else {
@sections = keys %subtotal;
}
@@ -3747,6 +3805,7 @@ sub _items_svc_phone_sections {
my %lines = ();
my %usage_class = map { $_->classnum => $_ } qsearch( 'usage_class', {} );
+ $usage_class{''} ||= new FS::usage_class { 'classname' => '', 'weight' => 0 };
foreach my $cust_bill_pkg ( $self->cust_bill_pkg ) {
next unless $cust_bill_pkg->pkgnum > 0;
@@ -3989,6 +4048,7 @@ sub _items_cust_bill_pkg {
my $section = $opt{section}->{description} if $opt{section};
my $summary_page = $opt{summary_page} || '';
my $multilocation = $opt{multilocation} || '';
+ my $multisection = $opt{multisection} || '';
my @b = ();
my ($s, $r, $u) = ( undef, undef, undef );
@@ -4010,7 +4070,8 @@ sub _items_cust_bill_pkg {
? $_->section eq $section
: 1
}
- grep { !$_->summary || !$summary_page }
+ #grep { !$_->summary || !$summary_page } # bunk!
+ grep { !$_->summary || $multisection }
$cust_bill_pkg->cust_bill_pkg_display
)
{
@@ -4039,15 +4100,20 @@ sub _items_cust_bill_pkg {
unless ( $cust_pkg->part_pkg->hide_svc_detail
|| $cust_bill_pkg->hidden )
{
+
push @d, map &{$escape_function}($_),
- $cust_pkg->h_labels_short($self->_date);
+ $cust_pkg->h_labels_short($self->_date)
+ unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
if ( $multilocation ) {
my $loc = $cust_pkg->location_label;
- $loc = substr($desc, 0, 50). '...'
+ $loc = substr($loc, 0, 50). '...'
if $format eq 'latex' && length($loc) > 50;
push @d, &{$escape_function}($loc);
}
+
}
+
push @d, $cust_bill_pkg->details(%details_opt)
if $cust_bill_pkg->recur == 0;
@@ -4069,7 +4135,7 @@ sub _items_cust_bill_pkg {
}
- if ( $cust_bill_pkg->recur != 0 &&
+ if ( ( $cust_bill_pkg->recur != 0 || $cust_bill_pkg->setup == 0 ) &&
( !$type || $type eq 'R' || $type eq 'U' )
)
{
@@ -4096,17 +4162,20 @@ sub _items_cust_bill_pkg {
|| $cust_bill_pkg->hidden
|| $is_summary && $type && $type eq 'U' )
{
+
push @d, map &{$escape_function}($_),
$cust_pkg->h_labels_short(@dates)
#$cust_bill_pkg->edate,
#$cust_bill_pkg->sdate)
- ;
+ unless $cust_bill_pkg->pkgpart_override; #don't redisplay services
+
if ( $multilocation ) {
my $loc = $cust_pkg->location_label;
- $loc = substr($desc, 0, 50). '...'
+ $loc = substr($loc, 0, 50). '...'
if $format eq 'latex' && length($loc) > 50;
push @d, &{$escape_function}($loc);
}
+
}
push @d, $cust_bill_pkg->details(%details_opt)
@@ -4139,7 +4208,7 @@ sub _items_cust_bill_pkg {
};
}
- } elsif ( $amount ) { # && $type eq 'U'
+ } else { # $type eq 'U'
if ( $cust_bill_pkg->hidden ) {
$u->{amount} += $amount;
@@ -4454,6 +4523,25 @@ sub credited_sql {
WHERE cust_bill.invnum = cust_credit_bill.invnum $start $end )";
}
+=item due_date_sql
+
+Returns an SQL fragment to retrieve the due date of an invoice.
+Currently only supported on PostgreSQL.
+
+=cut
+
+sub due_date_sql {
+'COALESCE(
+ SUBSTRING(
+ COALESCE(
+ cust_bill.invoice_terms,
+ cust_main.invoice_terms,
+ \''.($conf->config('invoice_default_terms') || '').'\'
+ ), E\'Net (\\\\d+)\'
+ )::INTEGER, 0
+) * 86400 + cust_bill._date'
+}
+
=item search_sql_where HASHREF
Class method which returns an SQL WHERE fragment to search for parameters
diff --git a/FS/FS/cust_bill_pkg_detail.pm b/FS/FS/cust_bill_pkg_detail.pm
index 4d9ee81..7badaa3 100644
--- a/FS/FS/cust_bill_pkg_detail.pm
+++ b/FS/FS/cust_bill_pkg_detail.pm
@@ -231,17 +231,17 @@ sub formatted {
}
-# _upgrade_data
-#
-# Used by FS::Upgrade to migrate to a new database.
-
-sub _upgrade_data { # class method
+# Used by FS::Upgrade to migrate to a new database schema
+sub _upgrade_schema { # class method
my ($class, %opts) = @_;
warn "$me upgrading $class\n" if $DEBUG;
- my $type = dbdef->table($class->table)->column('classnum')->type;
+ my $classnum = dbdef->table($class->table)->column('classnum')
+ or return;
+
+ my $type = $classnum->type;
unless ( $type =~ /^int/i || $type =~ /int$/i ) {
my $dbh = dbh;
@@ -313,51 +313,50 @@ sub _upgrade_data { # class method
}
+}
- if ( defined( dbdef->table($class->table)->column('billpkgnum') ) &&
- defined( dbdef->table($class->table)->column('invnum') ) &&
- defined( dbdef->table($class->table)->column('pkgnum') )
- ) {
-
- warn "$me Checking for unmigrated invoice line item details\n" if $DEBUG;
-
- my @cbpd = qsearch({ 'table' => $class->table,
- 'hashref' => {},
- 'extra_sql' => 'WHERE invnum IS NOT NULL AND '.
- 'pkgnum IS NOT NULL',
- });
-
- if (scalar(@cbpd)) {
- warn "$me Found unmigrated invoice line item details\n" if $DEBUG;
-
- foreach my $cbpd ( @cbpd ) {
- my $detailnum = $cbpd->detailnum;
- warn "$me Contemplating detail $detailnum\n" if $DEBUG > 1;
- my $cust_bill_pkg =
- qsearchs({ 'table' => 'cust_bill_pkg',
- 'hashref' => { 'invnum' => $cbpd->invnum,
- 'pkgnum' => $cbpd->pkgnum,
- },
- 'order_by' => 'ORDER BY billpkgnum LIMIT 1',
- });
- if ($cust_bill_pkg) {
- $cbpd->billpkgnum($cust_bill_pkg->billpkgnum);
- $cbpd->invnum('');
- $cbpd->pkgnum('');
- my $error = $cbpd->replace;
-
- warn "*** WARNING: error replacing line item detail ".
- "(cust_bill_pkg_detail) $detailnum: $error ***\n"
- if $error;
- } else {
- warn "Found orphaned line item detail $detailnum during upgrade.\n";
- }
+# Used by FS::Upgrade to migrate to a new database
+sub _upgrade_data { # class method
+
+ my ($class, %opts) = @_;
- } # foreach $cbpd
+ warn "$me Checking for unmigrated invoice line item details\n" if $DEBUG;
+
+ my @cbpd = qsearch({ 'table' => $class->table,
+ 'hashref' => {},
+ 'extra_sql' => 'WHERE invnum IS NOT NULL AND '.
+ 'pkgnum IS NOT NULL',
+ });
+
+ if (scalar(@cbpd)) {
+ warn "$me Found unmigrated invoice line item details\n" if $DEBUG;
+
+ foreach my $cbpd ( @cbpd ) {
+ my $detailnum = $cbpd->detailnum;
+ warn "$me Contemplating detail $detailnum\n" if $DEBUG > 1;
+ my $cust_bill_pkg =
+ qsearchs({ 'table' => 'cust_bill_pkg',
+ 'hashref' => { 'invnum' => $cbpd->invnum,
+ 'pkgnum' => $cbpd->pkgnum,
+ },
+ 'order_by' => 'ORDER BY billpkgnum LIMIT 1',
+ });
+ if ($cust_bill_pkg) {
+ $cbpd->billpkgnum($cust_bill_pkg->billpkgnum);
+ $cbpd->invnum('');
+ $cbpd->pkgnum('');
+ my $error = $cbpd->replace;
+
+ warn "*** WARNING: error replacing line item detail ".
+ "(cust_bill_pkg_detail) $detailnum: $error ***\n"
+ if $error;
+ } else {
+ warn "Found orphaned line item detail $detailnum during upgrade.\n";
+ }
- } # if @cbpd
+ } # foreach $cbpd
- } # if billpkgnum, invnum, and pkgnum columns defined
+ } # if @cbpd
'';
diff --git a/FS/FS/cust_bill_pkg_display.pm b/FS/FS/cust_bill_pkg_display.pm
index e9da18d..a864ec1 100644
--- a/FS/FS/cust_bill_pkg_display.pm
+++ b/FS/FS/cust_bill_pkg_display.pm
@@ -55,7 +55,7 @@ sub section {
my $section = $self->getfield('section');
unless ($section) {
my $cust_bill_pkg = $self->cust_bill_pkg;
- if ( $cust_bill_pkg->pkgnum > 0 ) {
+ if ( $cust_bill_pkg->pkgnum > 0 && !$cust_bill_pkg->hidden ) {
my $part_pkg = $cust_bill_pkg->part_pkg;
$section = $part_pkg->categoryname if $part_pkg;
}
diff --git a/FS/FS/cust_credit.pm b/FS/FS/cust_credit.pm
index 1ddcb8b..6185fc4 100644
--- a/FS/FS/cust_credit.pm
+++ b/FS/FS/cust_credit.pm
@@ -2,11 +2,14 @@ package FS::cust_credit;
use strict;
use base qw( FS::otaker_Mixin FS::cust_main_Mixin FS::Record );
-use vars qw( $conf $unsuspendauto $me $DEBUG $otaker_upgrade_kludge );
+use vars qw( $conf $unsuspendauto $me $DEBUG
+ $otaker_upgrade_kludge $ignore_empty_reasonnum
+ );
use Date::Format;
use FS::UID qw( dbh getotaker );
use FS::Misc qw(send_email);
use FS::Record qw( qsearch qsearchs dbdef );
+use FS::CurrentUser;
use FS::cust_main;
use FS::cust_pkg;
use FS::cust_refund;
@@ -20,6 +23,7 @@ $me = '[ FS::cust_credit ]';
$DEBUG = 0;
$otaker_upgrade_kludge = 0;
+$ignore_empty_reasonnum = 0;
#ask FS::UID to run this stuff for us later
$FS::UID::callback{'FS::cust_credit'} = sub {
@@ -266,14 +270,17 @@ sub delete {
}
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
You can, but probably shouldn't modify credits...
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record. If there is an error, returns the error,
+otherwise returns false.
+
=cut
sub replace {
- #return "Can't modify credit!"
my $self = shift;
return "Can't modify closed credit" if $self->closed =~ /^Y/i;
$self->SUPER::replace(@_);
@@ -290,7 +297,7 @@ methods.
sub check {
my $self = shift;
- $self->otaker(getotaker) unless ($self->otaker);
+ $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
my $error =
$self->ut_numbern('crednum')
@@ -299,7 +306,6 @@ sub check {
|| $self->ut_money('amount')
|| $self->ut_alphan('otaker')
|| $self->ut_textn('reason')
- || $self->ut_foreign_key('reasonnum', 'reason', 'reasonnum')
|| $self->ut_textn('addlinfo')
|| $self->ut_enum('closed', [ '', 'Y' ])
|| $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
@@ -307,6 +313,10 @@ sub check {
;
return $error if $error;
+ my $method = $ignore_empty_reasonnum ? 'ut_foreign_keyn' : 'ut_foreign_key';
+ $error = $self->$method('reasonnum', 'reason', 'reasonnum');
+ return $error if $error;
+
return "amount must be > 0 " if $self->amount <= 0;
return "amount must be greater or equal to amount applied"
@@ -551,6 +561,7 @@ sub _upgrade_data { # class method
}
local($otaker_upgrade_kludge) = 1;
+ local($ignore_empty_reasonnum) = 1;
$class->_upgrade_otaker(%opts);
}
diff --git a/FS/FS/cust_credit_bill_pkg.pm b/FS/FS/cust_credit_bill_pkg.pm
index 019a1a8..64f1f29 100644
--- a/FS/FS/cust_credit_bill_pkg.pm
+++ b/FS/FS/cust_credit_bill_pkg.pm
@@ -106,7 +106,10 @@ sub insert {
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 = $part_pkg ? $part_pkg->freq || 1 : 1;# assume unchanged
+ my $freq = $self->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 $credit_per_month = sprintf("%.2f", $self->amount / $freq ); #pennies?
@@ -334,13 +337,13 @@ sub cust_bill_pkg_tax_Xlocation {
B<setuprecur> field is a kludge to compensate for cust_bill_pkg having separate
setup and recur fields. It should be removed once that's fixed.
-B<insert> method assumes that the frequency of the package associated with the
-associated line item remains unchanged during the lifetime of the system.
-It may get the tax exemption adjustments wrong if package definitions change
-frequency. The presense of delete methods in FS::cust_main_county and
-FS::tax_rate makes crediting of old "texas tax" unreliable in the presense of
-changing taxes. Explicit tax credit requests? Carry 'taxable' onto line
-items?
+B<insert> method used to assume that the frequency of the package associated
+with the associated line item remained unchanged during the lifetime of the
+system. That is still used as a fallback. It may get the tax exemption
+adjustments wrong if package definitions change frequency. The presense of
+delete methods in FS::cust_main_county and FS::tax_rate makes crediting of
+old "texas tax" unreliable in the presense of changing taxes. Explicit tax
+credit requests? Carry 'taxable' onto line items?
=head1 SEE ALSO
diff --git a/FS/FS/cust_location.pm b/FS/FS/cust_location.pm
index 0c5c023..ab80941 100644
--- a/FS/FS/cust_location.pm
+++ b/FS/FS/cust_location.pm
@@ -1,7 +1,7 @@
package FS::cust_location;
use strict;
-use base qw( FS::Record );
+use base qw( FS::geocode_Mixin FS::Record );
use Locale::Country;
use FS::Record qw( qsearch ); #qsearchs );
use FS::prospect_main;
@@ -163,91 +163,35 @@ sub country_full {
code2country($self->country);
}
-=item location_label [ OPTION => VALUE ... ]
-
-Returns the label of the service location for this customer.
-
-Options are
-
-=over 4
-
-=item join_string
-
-used to separate the address elements (defaults to ', ')
-
-=item escape_function
-
-
-a callback used for escaping the text of the address elements
+=item line
-=back
+Synonym for location_label
=cut
-# false laziness with FS::cust_main::location_label
-
-sub location_label {
+sub line {
my $self = shift;
- my %opt = @_;
-
- my $separator = $opt{join_string} || ', ';
- my $escape = $opt{escape_function} || sub{ shift };
- my $ds = $opt{double_space} || ' ';
- my $line = '';
- my $cydefault =
- $opt{'countrydefault'} || FS::Conf->new->config('countrydefault') || 'US';
- my $prefix = '';
-
- my $notfirst = 0;
- foreach (qw ( address1 address2 ) ) {
- my $method = "$prefix$_";
- $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
- if $self->$method;
- $notfirst++;
- }
- $notfirst = 0;
- foreach (qw ( city county state zip ) ) {
- my $method = "$prefix$_";
- if ( $self->$method ) {
- $line .= ($notfirst ? ($method eq 'zip' ? $ds : ' ') : $separator);
- $line .= '(' if $method eq 'county';
- $line .= &$escape($self->$method);
- $line .= ')' if $method eq 'county';
- $notfirst++;
- }
- $line .= ',' if $method eq 'county';
- }
- $line .= $separator. &$escape(code2country($self->country))
- if $self->country ne $cydefault;
-
- $line;
+ $self->location_label;
}
-=item line
+=item has_ship_address
-Synonym for location_label
+Returns false since cust_location objects do not have a separate shipping
+address.
=cut
-sub line {
- my $self = shift;
- $self->location_label;
+sub has_ship_address {
+ '';
}
=item location_hash
-Returns a list of key/value pairs, with the following keys: address1, adddress2,
-city, county, state, zip, country.
+Returns a list of key/value pairs, with the following keys: address1, address2,
+city, county, state, zip, country, geocode.
=cut
-#geocode? not yet set
-
-sub location_hash {
- my $self = shift;
- map { $_ => $self->$_ } qw( address1 address2 city county state zip country );
-}
-
=back
=head1 BUGS
diff --git a/FS/FS/cust_main.pm b/FS/FS/cust_main.pm
index 002b0c1..7969965 100644
--- a/FS/FS/cust_main.pm
+++ b/FS/FS/cust_main.pm
@@ -2,19 +2,22 @@ package FS::cust_main;
require 5.006;
use strict;
-use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::Record );
-use vars qw( @EXPORT_OK $DEBUG $me $conf
+ #FS::cust_main:_Marketgear when they're ready to move to 2.1
+use base qw( FS::cust_main::Packages
+ FS::cust_main::Billing FS::cust_main::Billing_Realtime
+ FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
+ FS::geocode_Mixin
+ FS::Record
+ );
+use vars qw( $DEBUG $me $conf
@encrypted_fields
- $import $ignore_expired_card
+ $import
+ $ignore_expired_card $ignore_illegal_zip $ignore_banned_card
$skip_fuzzyfiles @fuzzyfields
@paytypes
);
-use vars qw( $realtime_bop_decline_quiet ); #ugh
-use Safe;
use Carp;
-use Exporter;
use Scalar::Util qw( blessed );
-use List::Util qw( min );
use Time::Local qw(timelocal);
use Storable qw(thaw);
use MIME::Base64;
@@ -23,22 +26,18 @@ use Tie::IxHash;
use Digest::MD5 qw(md5_base64);
use Date::Format;
#use Date::Manip;
-use File::Temp qw( tempfile );
-use String::Approx qw(amatch);
+use File::Temp; #qw( tempfile );
use Business::CreditCard 0.28;
use Locale::Country;
use FS::UID qw( getotaker dbh driver_name );
use FS::Record qw( qsearchs qsearch dbdef regexp_sql );
use FS::Misc qw( generate_email send_email generate_ps do_print );
use FS::Msgcat qw(gettext);
+use FS::CurrentUser;
use FS::payby;
use FS::cust_pkg;
use FS::cust_svc;
use FS::cust_bill;
-use FS::cust_bill_pkg;
-use FS::cust_bill_pkg_display;
-use FS::cust_bill_pkg_tax_location;
-use FS::cust_bill_pkg_tax_rate_location;
use FS::cust_pay;
use FS::cust_pay_pending;
use FS::cust_pay_void;
@@ -51,20 +50,13 @@ use FS::cust_location;
use FS::cust_class;
use FS::cust_main_exemption;
use FS::cust_tax_adjustment;
-use FS::tax_rate;
-use FS::tax_rate_location;
use FS::cust_tax_location;
-use FS::part_pkg_taxrate;
use FS::agent;
use FS::cust_main_invoice;
use FS::cust_tag;
-use FS::cust_credit_bill;
-use FS::cust_bill_pay;
use FS::prepay_credit;
use FS::queue;
use FS::part_pkg;
-use FS::part_event;
-use FS::part_event_condition;
use FS::part_export;
#use FS::cust_event;
use FS::type_pkgs;
@@ -73,10 +65,6 @@ use FS::agent_payment_gateway;
use FS::banned_pay;
use FS::TicketSystem;
-@EXPORT_OK = qw( smart_search );
-
-$realtime_bop_decline_quiet = 0;
-
# 1 is mostly method/subroutine entry and options
# 2 traces progress of some operations
# 3 is even more information including possibly sensitive data
@@ -85,6 +73,8 @@ $me = '[FS::cust_main]';
$import = 0;
$ignore_expired_card = 0;
+$ignore_illegal_zip = 0;
+$ignore_banned_card = 0;
$skip_fuzzyfiles = 0;
@fuzzyfields = ( 'first', 'last', 'company', 'address1' );
@@ -516,18 +506,12 @@ sub insert {
}
}
- if ( $conf->config('cust_main-skeleton_tables')
- && $conf->config('cust_main-skeleton_custnum') ) {
-
- warn " inserting skeleton records\n"
- if $DEBUG > 1;
-
+ if ( $self->can('start_copy_skel') ) {
my $error = $self->start_copy_skel;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return $error;
}
-
}
warn " ordering packages\n"
@@ -650,332 +634,10 @@ sub auto_agent_custid {
}
-sub start_copy_skel {
- my $self = shift;
-
- #'mg_user_preference' => {},
- #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
- #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
- #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
- #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
- my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
- die $@ if $@;
-
- _copy_skel( 'cust_main', #tablename
- $conf->config('cust_main-skeleton_custnum'), #sourceid
- $self->custnum, #destid
- @tables, #child tables
- );
-}
-
-#recursive subroutine, not a method
-sub _copy_skel {
- my( $table, $sourceid, $destid, %child_tables ) = @_;
-
- my $primary_key;
- if ( $table =~ /^(\w+)\.(\w+)$/ ) {
- ( $table, $primary_key ) = ( $1, $2 );
- } else {
- my $dbdef_table = dbdef->table($table);
- $primary_key = $dbdef_table->primary_key
- or return "$table has no primary key".
- " (or do you need to run dbdef-create?)";
- }
-
- warn " _copy_skel: $table.$primary_key $sourceid to $destid for ".
- join (', ', keys %child_tables). "\n"
- if $DEBUG > 2;
-
- foreach my $child_table_def ( keys %child_tables ) {
-
- my $child_table;
- my $child_pkey = '';
- if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
- ( $child_table, $child_pkey ) = ( $1, $2 );
- } else {
- $child_table = $child_table_def;
-
- $child_pkey = dbdef->table($child_table)->primary_key;
- # or return "$table has no primary key".
- # " (or do you need to run dbdef-create?)\n";
- }
-
- my $sequence = '';
- if ( keys %{ $child_tables{$child_table_def} } ) {
-
- return "$child_table has no primary key".
- " (run dbdef-create or try specifying it?)\n"
- unless $child_pkey;
-
- #false laziness w/Record::insert and only works on Pg
- #refactor the proper last-inserted-id stuff out of Record::insert if this
- # ever gets use for anything besides a quick kludge for one customer
- my $default = dbdef->table($child_table)->column($child_pkey)->default;
- $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
- or return "can't parse $child_table.$child_pkey default value ".
- " for sequence name: $default";
- $sequence = $1;
-
- }
-
- my @sel_columns = grep { $_ ne $primary_key }
- dbdef->table($child_table)->columns;
- my $sel_columns = join(', ', @sel_columns );
-
- my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
- my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
- my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
-
- my $sel_st = "SELECT $sel_columns FROM $child_table".
- " WHERE $primary_key = $sourceid";
- warn " $sel_st\n"
- if $DEBUG > 2;
- my $sel_sth = dbh->prepare( $sel_st )
- or return dbh->errstr;
-
- $sel_sth->execute or return $sel_sth->errstr;
-
- while ( my $row = $sel_sth->fetchrow_hashref ) {
-
- warn " selected row: ".
- join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
- if $DEBUG > 2;
-
- my $statement =
- "INSERT INTO $child_table $ins_columns VALUES $placeholders";
- my $ins_sth =dbh->prepare($statement)
- or return dbh->errstr;
- my @param = ( $destid, map $row->{$_}, @ins_columns );
- warn " $statement: [ ". join(', ', @param). " ]\n"
- if $DEBUG > 2;
- $ins_sth->execute( @param )
- or return $ins_sth->errstr;
-
- #next unless keys %{ $child_tables{$child_table} };
- next unless $sequence;
-
- #another section of that laziness
- my $seq_sql = "SELECT currval('$sequence')";
- my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
- $seq_sth->execute or return $seq_sth->errstr;
- my $insertid = $seq_sth->fetchrow_arrayref->[0];
-
- # don't drink soap! recurse! recurse! okay!
- my $error =
- _copy_skel( $child_table_def,
- $row->{$child_pkey}, #sourceid
- $insertid, #destid
- %{ $child_tables{$child_table_def} },
- );
- return $error if $error;
-
- }
-
- }
-
- return '';
-
-}
-
-=item order_pkg HASHREF | OPTION => VALUE ...
-
-Orders a single package.
-
-Options may be passed as a list of key/value pairs or as a hash reference.
-Options are:
-
-=over 4
-
-=item cust_pkg
-
-FS::cust_pkg object
-
-=item cust_location
-
-Optional FS::cust_location object
-
-=item svcs
-
-Optional arryaref of FS::svc_* service objects.
-
-=item depend_jobnum
-
-If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
-jobs will have a dependancy on the supplied job (they will not run until the
-specific job completes). This can be used to defer provisioning until some
-action completes (such as running the customer's credit card successfully).
-
-=item ticket_subject
-
-Optional subject for a ticket created and attached to this customer
-
-=item ticket_subject
-
-Optional queue name for ticket additions
-
-=back
-
-=cut
-
-sub order_pkg {
- my $self = shift;
- my $opt = ref($_[0]) ? shift : { @_ };
-
- warn "$me order_pkg called with options ".
- join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
- if $DEBUG;
-
- my $cust_pkg = $opt->{'cust_pkg'};
- my $svcs = $opt->{'svcs'} || [];
-
- my %svc_options = ();
- $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
- if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
-
- my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
- qw( ticket_subject ticket_queue );
-
- 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;
-
- if ( $opt->{'cust_location'} &&
- ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) {
- my $error = $opt->{'cust_location'}->insert;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "inserting cust_location (transaction rolled back): $error";
- }
- $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
- }
-
- $cust_pkg->custnum( $self->custnum );
-
- my $error = $cust_pkg->insert( %insert_params );
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "inserting cust_pkg (transaction rolled back): $error";
- }
-
- foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
- if ( $svc_something->svcnum ) {
- my $old_cust_svc = $svc_something->cust_svc;
- my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
- $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
- $error = $new_cust_svc->replace($old_cust_svc);
- } else {
- $svc_something->pkgnum( $cust_pkg->pkgnum );
- if ( $svc_something->isa('FS::svc_acct') ) {
- foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } }
- qw( seconds upbytes downbytes totalbytes ) ) {
- $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } );
- ${ $opt->{$_.'_ref'} } = 0;
- }
- }
- $error = $svc_something->insert(%svc_options);
- }
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "inserting svc_ (transaction rolled back): $error";
- }
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- ''; #no error
-
-}
-
-#deprecated #=item order_pkgs HASHREF [ , SECONDSREF ] [ , OPTION => VALUE ... ]
-=item order_pkgs HASHREF [ , OPTION => VALUE ... ]
-
-Like the insert method on an existing record, this method orders multiple
-packages and included services atomicaly. Pass a Tie::RefHash data structure
-to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
-There should be a better explanation of this, but until then, here's an
-example:
-
- use Tie::RefHash;
- tie %hash, 'Tie::RefHash'; #this part is important
- %hash = (
- $cust_pkg => [ $svc_acct ],
- ...
- );
- $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
-
-Services can be new, in which case they are inserted, or existing unaudited
-services, in which case they are linked to the newly-created package.
-
-Currently available options are: I<depend_jobnum>, I<noexport>, I<seconds_ref>,
-I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.
-
-If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
-on the supplied jobnum (they will not run until the specific job completes).
-This can be used to defer provisioning until some action completes (such
-as running the customer's credit card successfully).
-
-The I<noexport> option is deprecated. If I<noexport> is set true, no
-provisioning jobs (exports) are scheduled. (You can schedule them later with
-the B<reexport> method for each cust_pkg object. Using the B<reexport> method
-on the cust_main object is not recommended, as existing services will also be
-reexported.)
-
-If I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, or I<totalbytes_ref> is
-provided, the scalars (provided by references) will be incremented by the
-values of the prepaid card.`
-
-=cut
-
-sub order_pkgs {
- my $self = shift;
- my $cust_pkgs = shift;
- my $seconds_ref = ref($_[0]) ? shift : ''; #deprecated
- my %options = @_;
- $seconds_ref ||= $options{'seconds_ref'};
-
- warn "$me order_pkgs called with options ".
- join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
- if $DEBUG;
-
- 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;
-
- local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
-
- foreach my $cust_pkg ( keys %$cust_pkgs ) {
-
- my $error = $self->order_pkg(
- 'cust_pkg' => $cust_pkg,
- 'svcs' => $cust_pkgs->{$cust_pkg},
- 'seconds_ref' => $seconds_ref,
- map { $_ => $options{$_} } qw( upbytes_ref downbytes_ref totalbytes_ref
- depend_jobnum
- )
- );
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
-
- }
+=item PACKAGE METHODS
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- ''; #no error
-}
+Documentation on customer package methods has been moved to
+L<FS::cust_main::Packages>.
=item recharge_prepay IDENTIFIER | PREPAY_CREDIT_OBJ [ , AMOUNTREF, SECONDSREF, UPBYTEREF, DOWNBYTEREF ]
@@ -1301,7 +963,7 @@ sub reexport {
}
-=item delete NEW_CUSTNUM
+=item delete [ OPTION => VALUE ... ]
This deletes the customer. If there is an error, returns the error, otherwise
returns false.
@@ -1311,18 +973,20 @@ what you want when a customer cancels service; for that, cancel all of the
customer's packages (see L</cancel>).
If the customer has any uncancelled packages, you need to pass a new (valid)
-customer number for those packages to be transferred to. Cancelled packages
-will be deleted. Did I mention that this is NOT what you want when a customer
-cancels service and that you really should be looking see L<FS::cust_pkg/cancel>?
+customer number for those packages to be transferred to, as the "new_customer"
+option. Cancelled packages will be deleted. Did I mention that this is NOT
+what you want when a customer cancels service and that you really should be
+looking at L<FS::cust_pkg/cancel>?
You can't delete a customer with invoices (see L<FS::cust_bill>),
-or credits (see L<FS::cust_credit>), payments (see L<FS::cust_pay>) or
-refunds (see L<FS::cust_refund>).
+statements (see L<FS::cust_statement>), credits (see L<FS::cust_credit>),
+payments (see L<FS::cust_pay>) or refunds (see L<FS::cust_refund>), unless you
+set the "delete_financials" option to a true value.
=cut
sub delete {
- my $self = shift;
+ my( $self, %opt ) = @_;
local $SIG{HUP} = 'IGNORE';
local $SIG{INT} = 'IGNORE';
@@ -1335,26 +999,47 @@ sub delete {
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
- if ( $self->cust_bill ) {
- $dbh->rollback if $oldAutoCommit;
- return "Can't delete a customer with invoices";
- }
- if ( $self->cust_credit ) {
- $dbh->rollback if $oldAutoCommit;
- return "Can't delete a customer with credits";
+ if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Can't delete a master agent customer";
}
- if ( $self->cust_pay ) {
- $dbh->rollback if $oldAutoCommit;
- return "Can't delete a customer with payments";
+
+ #use FS::access_user
+ if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Can't delete a master employee customer";
}
- if ( $self->cust_refund ) {
- $dbh->rollback if $oldAutoCommit;
- return "Can't delete a customer with refunds";
+
+ tie my %financial_tables, 'Tie::IxHash',
+ 'cust_bill' => 'invoices',
+ 'cust_statement' => 'statements',
+ 'cust_credit' => 'credits',
+ 'cust_pay' => 'payments',
+ 'cust_refund' => 'refunds',
+ ;
+
+ foreach my $table ( keys %financial_tables ) {
+
+ my @records = $self->$table();
+
+ if ( @records && ! $opt{'delete_financials'} ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Can't delete a customer with ". $financial_tables{$table};
+ }
+
+ foreach my $record ( @records ) {
+ my $error = $record->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Error deleting ". $financial_tables{$table}. ": $error\n";
+ }
+ }
+
}
my @cust_pkg = $self->ncancelled_pkgs;
if ( @cust_pkg ) {
- my $new_custnum = shift;
+ my $new_custnum = $opt{'new_custnum'};
unless ( qsearchs( 'cust_main', { 'custnum' => $new_custnum } ) ) {
$dbh->rollback if $oldAutoCommit;
return "Invalid new customer number: $new_custnum";
@@ -1381,8 +1066,15 @@ sub delete {
}
}
- foreach my $table (qw( cust_main_invoice cust_main_exemption cust_tag )) {
- foreach my $record ( qsearch( 'table', { 'custnum' => $self->custnum } ) ) {
+ #cust_tax_adjustment in financials?
+ #cust_pay_pending? ouch
+ #cust_recon?
+ foreach my $table (qw(
+ cust_main_invoice cust_main_exemption cust_tag cust_attachment contact
+ cust_location cust_main_note cust_tax_adjustment
+ cust_pay_void cust_pay_batch queue cust_tax_exempt
+ )) {
+ foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
my $error = $record->delete;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
@@ -1391,6 +1083,54 @@ sub delete {
}
}
+ my $sth = $dbh->prepare(
+ 'UPDATE cust_main SET referral_custnum = NULL WHERE referral_custnum = ?'
+ ) or do {
+ my $errstr = $dbh->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+ $sth->execute($self->custnum) or do {
+ my $errstr = $sth->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+
+ #tickets
+
+ my $ticket_dbh = '';
+ if ($conf->config('ticket_system') eq 'RT_Internal') {
+ $ticket_dbh = $dbh;
+ } elsif ($conf->config('ticket_system') eq 'RT_External') {
+ my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
+ $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
+ #or die "RT_External DBI->connect error: $DBI::errstr\n";
+ }
+
+ if ( $ticket_dbh ) {
+
+ my $ticket_sth = $ticket_dbh->prepare(
+ 'DELETE FROM Links WHERE Target = ?'
+ ) or do {
+ my $errstr = $ticket_dbh->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+ $ticket_sth->execute('freeside://freeside/cust_main/'.$self->custnum)
+ or do {
+ my $errstr = $ticket_sth->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+
+ #check and see if the customer is the only link on the ticket, and
+ #if so, set the ticket to deleted status in RT?
+ #maybe someday, for now this will at least fix tickets not displaying
+
+ }
+
+ #delete the customer record
+
my $error = $self->SUPER::delete;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
@@ -1419,6 +1159,227 @@ sub delete {
}
+=item merge NEW_CUSTNUM [ , OPTION => VALUE ... ]
+
+This merges this customer into the provided new custnum, and then deletes the
+customer. If there is an error, returns the error, otherwise returns false.
+
+The source customer's name, company name, phone numbers, agent,
+referring customer, customer class, advertising source, order taker, and
+billing information (except balance) are discarded.
+
+All packages are moved to the target customer. Packages with package locations
+are preserved. Packages without package locations are moved to a new package
+location with the source customer's service/shipping address.
+
+All invoices, statements, payments, credits and refunds are moved to the target
+customer. The source customer's balance is added to the target customer.
+
+All notes, attachments, tickets and customer tags are moved to the target
+customer.
+
+Change history is not currently moved.
+
+=cut
+
+sub merge {
+ my( $self, $new_custnum, %opt ) = @_;
+
+ 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";
+ }
+
+ 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;
+
+ if ( qsearch('agent', { 'agent_custnum' => $self->custnum } ) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Can't merge a master agent customer";
+ }
+
+ #use FS::access_user
+ if ( qsearch('access_user', { 'user_custnum' => $self->custnum } ) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Can't merge a master employee customer";
+ }
+
+ if ( qsearch('cust_pay_pending', { 'custnum' => $self->custnum,
+ 'status' => { op=>'!=', value=>'done' },
+ }
+ )
+ ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Can't merge a customer with pending payments";
+ }
+
+ tie my %financial_tables, 'Tie::IxHash',
+ 'cust_bill' => 'invoices',
+ 'cust_statement' => 'statements',
+ 'cust_credit' => 'credits',
+ 'cust_pay' => 'payments',
+ 'cust_pay_void' => 'voided payments',
+ 'cust_refund' => 'refunds',
+ ;
+
+ foreach my $table ( keys %financial_tables ) {
+
+ my @records = $self->$table();
+
+ foreach my $record ( @records ) {
+ $record->custnum($new_custnum);
+ my $error = $record->replace;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Error merging ". $financial_tables{$table}. ": $error\n";
+ }
+ }
+
+ }
+
+ my $name = $self->ship_name;
+
+ my $locationnum = '';
+ foreach my $cust_pkg ( $self->all_pkgs ) {
+ $cust_pkg->custnum($new_custnum);
+
+ unless ( $cust_pkg->locationnum ) {
+ unless ( $locationnum ) {
+ my $cust_location = new FS::cust_location {
+ $self->location_hash,
+ 'custnum' => $new_custnum,
+ };
+ my $error = $cust_location->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ $locationnum = $cust_location->locationnum;
+ }
+ $cust_pkg->locationnum($locationnum);
+ }
+
+ my $error = $cust_pkg->replace;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ # add customer (ship) name to svc_phone.phone_name if blank
+ my @cust_svc = $cust_pkg->cust_svc;
+ foreach my $cust_svc (@cust_svc) {
+ my($label, $value, $svcdb) = $cust_svc->label;
+ next unless $svcdb eq 'svc_phone';
+ my $svc_phone = $cust_svc->svc_x;
+ next if $svc_phone->phone_name;
+ $svc_phone->phone_name($name);
+ my $error = $svc_phone->replace;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ }
+
+ #not considered:
+ # cust_tax_exempt (texas tax exemptions)
+ # cust_recon (some sort of not-well understood thing for OnPac)
+
+ #these are moved over
+ foreach my $table (qw(
+ cust_tag cust_location contact cust_attachment cust_main_note
+ cust_tax_adjustment cust_pay_batch queue
+ )) {
+ foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+ $record->custnum($new_custnum);
+ my $error = $record->replace;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+ }
+
+ #these aren't preserved
+ foreach my $table (qw(
+ cust_main_exemption cust_main_invoice
+ )) {
+ foreach my $record ( qsearch( $table, { 'custnum' => $self->custnum } ) ) {
+ my $error = $record->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+ }
+
+
+ my $sth = $dbh->prepare(
+ 'UPDATE cust_main SET referral_custnum = ? WHERE referral_custnum = ?'
+ ) or do {
+ my $errstr = $dbh->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+ $sth->execute($new_custnum, $self->custnum) or do {
+ my $errstr = $sth->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+
+ #tickets
+
+ my $ticket_dbh = '';
+ if ($conf->config('ticket_system') eq 'RT_Internal') {
+ $ticket_dbh = $dbh;
+ } elsif ($conf->config('ticket_system') eq 'RT_External') {
+ my ($datasrc, $user, $pass) = $conf->config('ticket_system-rt_external_datasrc');
+ $ticket_dbh = DBI->connect($datasrc, $user, $pass, { 'ChopBlanks' => 1 });
+ #or die "RT_External DBI->connect error: $DBI::errstr\n";
+ }
+
+ if ( $ticket_dbh ) {
+
+ my $ticket_sth = $ticket_dbh->prepare(
+ 'UPDATE Links SET Target = ? WHERE Target = ?'
+ ) or do {
+ my $errstr = $ticket_dbh->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+ $ticket_sth->execute('freeside://freeside/cust_main/'.$new_custnum,
+ 'freeside://freeside/cust_main/'.$self->custnum)
+ or do {
+ my $errstr = $ticket_sth->errstr;
+ $dbh->rollback if $oldAutoCommit;
+ return $errstr;
+ };
+
+ }
+
+ #delete the customer record
+
+ my $error = $self->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
+
+}
+
=item replace [ OLD_RECORD ] [ INVOICING_LIST_ARYREF ] [ , OPTION => VALUE ... ] ]
@@ -1460,6 +1421,17 @@ sub replace {
return "You are not permitted to create complimentary accounts.";
}
+ if ( $old->get('geocode') && $old->get('geocode') eq $self->get('geocode')
+ && $conf->exists('enable_taxproducts')
+ )
+ {
+ my $pre = ($conf->exists('tax-ship_address') && $self->ship_zip)
+ ? 'ship_' : '';
+ $self->set('geocode', '')
+ if $old->get($pre.'zip') ne $self->get($pre.'zip')
+ && length($self->get($pre.'zip')) >= 10;
+ }
+
local($ignore_expired_card) = 1
if $old->payby =~ /^(CARD|DCRD)$/
&& $self->payby =~ /^(CARD|DCRD)$/
@@ -1676,6 +1648,7 @@ sub check {
|| $self->ut_textn('invoice_terms')
|| $self->ut_alphan('geocode')
|| $self->ut_floatn('cdr_termination_percentage')
+ || $self->ut_floatn('credit_limit')
;
#barf. need message catalogs. i18n. etc.
@@ -1712,7 +1685,8 @@ sub check {
# bad idea to disable, causes billing to fail because of no tax rates later
-# unless ( $import ) {
+# except we don't fail any more
+ unless ( $import ) {
unless ( qsearch('cust_main_county', {
'country' => $self->country,
'state' => '',
@@ -1725,16 +1699,20 @@ sub check {
'country' => $self->country,
} );
}
-# }
+ }
$error =
$self->ut_phonen('daytime', $self->country)
|| $self->ut_phonen('night', $self->country)
|| $self->ut_phonen('fax', $self->country)
- || $self->ut_zip('zip', $self->country)
;
return $error if $error;
+ unless ( $ignore_illegal_zip ) {
+ $error = $self->ut_zip('zip', $self->country);
+ return $error if $error;
+ }
+
if ( $conf->exists('cust_main-require_phone')
&& ! length($self->daytime) && ! length($self->night)
) {
@@ -1787,10 +1765,13 @@ sub check {
$self->ut_phonen('ship_daytime', $self->ship_country)
|| $self->ut_phonen('ship_night', $self->ship_country)
|| $self->ut_phonen('ship_fax', $self->ship_country)
- || $self->ut_zip('ship_zip', $self->ship_country)
;
return $error if $error;
+ unless ( $ignore_illegal_zip ) {
+ $error = $self->ut_zip('ship_zip', $self->ship_country);
+ return $error if $error;
+ }
return "Unit # is required."
if $self->ship_address2 =~ /^\s*$/
&& $conf->exists('cust_main-require_address2');
@@ -1845,12 +1826,14 @@ sub check {
if $self->payinfo !~ /^99\d{14}$/ #token
&& cardtype($self->payinfo) eq "Unknown";
- my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
- if ( $ban ) {
- return 'Banned credit card: banned on '.
- time2str('%a %h %o at %r', $ban->_date).
- ' by '. $ban->otaker.
- ' (ban# '. $ban->bannum. ')';
+ unless ( $ignore_banned_card ) {
+ my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
+ if ( $ban ) {
+ return 'Banned credit card: banned on '.
+ time2str('%a %h %o at %r', $ban->_date).
+ ' by '. $ban->otaker.
+ ' (ban# '. $ban->bannum. ')';
+ }
}
if (length($self->paycvv) && !$self->is_encrypted($self->paycvv)) {
@@ -1905,12 +1888,14 @@ sub check {
$self->payinfo($payinfo);
$self->paycvv('');
- my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
- if ( $ban ) {
- return 'Banned ACH account: banned on '.
- time2str('%a %h %o at %r', $ban->_date).
- ' by '. $ban->otaker.
- ' (ban# '. $ban->bannum. ')';
+ unless ( $ignore_banned_card ) {
+ my $ban = qsearchs('banned_pay', $self->_banned_pay_hashref);
+ if ( $ban ) {
+ return 'Banned ACH account: banned on '.
+ time2str('%a %h %o at %r', $ban->_date).
+ ' by '. $ban->otaker.
+ ' (ban# '. $ban->bannum. ')';
+ }
}
} elsif ( $self->payby eq 'LECB' ) {
@@ -1970,6 +1955,7 @@ sub check {
} else {
return "Illegal expiration date: ". $self->paydate;
}
+ $m = sprintf('%02d',$m);
$self->paydate("$y-$m-01");
my($nowm,$nowy)=(localtime(time))[4,5]; $nowm++; $nowy+=1900;
return gettext('expired_card')
@@ -1984,7 +1970,7 @@ sub check {
) {
$self->payname( $self->first. " ". $self->getfield('last') );
} else {
- $self->payname =~ /^([\w \,\.\-\'\&]+)$/
+ $self->payname =~ /^([µ_0123456789aAáÁàÀâÂåÅäÄãêæÆbBcCçÇdDðÐeEéÉèÈêÊëËfFgGhHiIíÍìÌîÎïÏjJkKlLmMnNñÑoOóÓòÒôÔöÖõÕøغpPqQrRsSßtTuUúÚùÙûÛüÜvVwWxXyYýÝÿzZþÞ \,\.\-\'\&]+)$/
or return gettext('illegal_name'). " payname: ". $self->payname;
$self->payname($1);
}
@@ -1994,7 +1980,7 @@ sub check {
$self->$flag($1);
}
- $self->otaker(getotaker) unless $self->otaker;
+ $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
warn "$me check AFTER: \n". $self->_dump
if $DEBUG > 2;
@@ -2029,54 +2015,10 @@ sub has_ship_address {
=item location_hash
Returns a list of key/value pairs, with the following keys: address1, adddress2,
-city, county, state, zip, country. The shipping address is used if present.
-
-=cut
-
-#geocode? dependent on tax-ship_address config, not available in cust_location
-#mostly. not yet then.
-
-sub location_hash {
- my $self = shift;
- my $prefix = $self->has_ship_address ? 'ship_' : '';
-
- map { $_ => $self->get($prefix.$_) }
- qw( address1 address2 city county state zip country geocode );
- #fields that cust_location has
-}
-
-=item all_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
-
-Returns all packages (see L<FS::cust_pkg>) for this customer.
-
-=cut
-
-sub all_pkgs {
- my $self = shift;
- my $extra_qsearch = ref($_[0]) ? shift : {};
-
- return $self->num_pkgs unless wantarray || keys(%$extra_qsearch);
-
- my @cust_pkg = ();
- if ( $self->{'_pkgnum'} ) {
- @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
- } else {
- @cust_pkg = $self->_cust_pkg($extra_qsearch);
- }
-
- sort sort_packages @cust_pkg;
-}
-
-=item cust_pkg
-
-Synonym for B<all_pkgs>.
+city, county, state, zip, country, and geocode. The shipping address is used if present.
=cut
-sub cust_pkg {
- shift->all_pkgs(@_);
-}
-
=item cust_location
Returns all locations (see L<FS::cust_location>) for this customer.
@@ -2088,221 +2030,6 @@ sub cust_location {
qsearch('cust_location', { 'custnum' => $self->custnum } );
}
-=item location_label [ OPTION => VALUE ... ]
-
-Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
-
-Options are
-
-=over 4
-
-=item join_string
-
-used to separate the address elements (defaults to ', ')
-
-=item escape_function
-
-a callback used for escaping the text of the address elements
-
-=back
-
-=cut
-
-# false laziness with FS::cust_location::line
-
-sub location_label {
- my $self = shift;
- my %opt = @_;
-
- my $separator = $opt{join_string} || ', ';
- my $escape = $opt{escape_function} || sub{ shift };
- my $line = '';
- my $cydefault = FS::conf->new->config('countrydefault') || 'US';
- my $prefix = length($self->ship_last) ? 'ship_' : '';
-
- my $notfirst = 0;
- foreach (qw ( address1 address2 ) ) {
- my $method = "$prefix$_";
- $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
- if $self->$method;
- $notfirst++;
- }
- $notfirst = 0;
- foreach (qw ( city county state zip ) ) {
- my $method = "$prefix$_";
- if ( $self->$method ) {
- $line .= ' (' if $method eq 'county';
- $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
- $line .= ' )' if $method eq 'county';
- $notfirst++;
- }
- }
- $line .= $separator. &$escape(code2country($self->country))
- if $self->country ne $cydefault;
-
- $line;
-}
-
-=item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
-
-Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
-
-=cut
-
-sub ncancelled_pkgs {
- my $self = shift;
- my $extra_qsearch = ref($_[0]) ? shift : {};
-
- return $self->num_ncancelled_pkgs unless wantarray;
-
- my @cust_pkg = ();
- if ( $self->{'_pkgnum'} ) {
-
- warn "$me ncancelled_pkgs: returning cached objects"
- if $DEBUG > 1;
-
- @cust_pkg = grep { ! $_->getfield('cancel') }
- values %{ $self->{'_pkgnum'}->cache };
-
- } else {
-
- warn "$me ncancelled_pkgs: searching for packages with custnum ".
- $self->custnum. "\n"
- if $DEBUG > 1;
-
- $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) ';
-
- @cust_pkg = $self->_cust_pkg($extra_qsearch);
-
- }
-
- sort sort_packages @cust_pkg;
-
-}
-
-sub _cust_pkg {
- my $self = shift;
- my $extra_qsearch = ref($_[0]) ? shift : {};
-
- $extra_qsearch->{'select'} ||= '*';
- $extra_qsearch->{'select'} .=
- ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum )
- AS _num_cust_svc';
-
- map {
- $_->{'_num_cust_svc'} = $_->get('_num_cust_svc');
- $_;
- }
- qsearch({
- %$extra_qsearch,
- 'table' => 'cust_pkg',
- 'hashref' => { 'custnum' => $self->custnum },
- });
-
-}
-
-# This should be generalized to use config options to determine order.
-sub sort_packages {
-
- my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
- return $locationsort if $locationsort;
-
- if ( $a->get('cancel') xor $b->get('cancel') ) {
- return -1 if $b->get('cancel');
- return 1 if $a->get('cancel');
- #shouldn't get here...
- return 0;
- } else {
- my $a_num_cust_svc = $a->num_cust_svc;
- my $b_num_cust_svc = $b->num_cust_svc;
- return 0 if !$a_num_cust_svc && !$b_num_cust_svc;
- return -1 if $a_num_cust_svc && !$b_num_cust_svc;
- return 1 if !$a_num_cust_svc && $b_num_cust_svc;
- my @a_cust_svc = $a->cust_svc;
- my @b_cust_svc = $b->cust_svc;
- return 0 if !scalar(@a_cust_svc) && !scalar(@b_cust_svc);
- return -1 if scalar(@a_cust_svc) && !scalar(@b_cust_svc);
- return 1 if !scalar(@a_cust_svc) && scalar(@b_cust_svc);
- $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
- }
-
-}
-
-=item suspended_pkgs
-
-Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
-
-=cut
-
-sub suspended_pkgs {
- my $self = shift;
- grep { $_->susp } $self->ncancelled_pkgs;
-}
-
-=item unflagged_suspended_pkgs
-
-Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
-customer (thouse packages without the `manual_flag' set).
-
-=cut
-
-sub unflagged_suspended_pkgs {
- my $self = shift;
- return $self->suspended_pkgs
- unless dbdef->table('cust_pkg')->column('manual_flag');
- grep { ! $_->manual_flag } $self->suspended_pkgs;
-}
-
-=item unsuspended_pkgs
-
-Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
-this customer.
-
-=cut
-
-sub unsuspended_pkgs {
- my $self = shift;
- grep { ! $_->susp } $self->ncancelled_pkgs;
-}
-
-=item next_bill_date
-
-Returns the next date this customer will be billed, as a UNIX timestamp, or
-undef if no active package has a next bill date.
-
-=cut
-
-sub next_bill_date {
- my $self = shift;
- min( map $_->get('bill'), grep $_->get('bill'), $self->unsuspended_pkgs );
-}
-
-=item num_cancelled_pkgs
-
-Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
-customer.
-
-=cut
-
-sub num_cancelled_pkgs {
- shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
-}
-
-sub num_ncancelled_pkgs {
- shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
-}
-
-sub num_pkgs {
- my( $self ) = shift;
- my $sql = scalar(@_) ? shift : '';
- $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
- my $sth = dbh->prepare(
- "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
- ) or die dbh->errstr;
- $sth->execute($self->custnum) or die $sth->errstr;
- $sth->fetchrow_arrayref->[0];
-}
-
=item unsuspend
Unsuspends all unflagged suspended packages (see L</unflagged_suspended_pkgs>
@@ -2583,2734 +2310,15 @@ sub classname {
: '';
}
+=item BILLING METHODS
-=item bill_and_collect
-
-Cancels and suspends any packages due, generates bills, applies payments and
-credits, and applies collection events to run cards, send bills and notices,
-etc.
-
-By default, warns on errors and continues with the next operation (but see the
-"fatal" flag below).
-
-Options are passed as name-value pairs. Currently available options are:
-
-=over 4
-
-=item time
-
-Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
-
- use Date::Parse;
- ...
- $cust_main->bill( 'time' => str2time('April 20th, 2001') );
-
-=item invoice_time
-
-Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
-
-=item check_freq
-
-"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
-
-=item resetup
-
-If set true, re-charges setup fees.
-
-=item fatal
-
-If set any errors prevent subsequent operations from continusing. If set
-specifically to "return", returns the error (or false, if there is no error).
-Any other true value causes errors to die.
-
-=item debug
-
-Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=item job
-
-Optional FS::queue entry to receive status updates.
-
-=back
-
-Options are passed to the B<bill> and B<collect> methods verbatim, so all
-options of those methods are also available.
-
-=cut
-
-sub bill_and_collect {
- my( $self, %options ) = @_;
-
- my $error;
-
- #$options{actual_time} not $options{time} because freeside-daily -d is for
- #pre-printing invoices
-
- $options{'actual_time'} ||= time;
- my $job = $options{'job'};
-
- $job->update_statustext('0,cleaning expired packages') if $job;
- $error = $self->cancel_expired_pkgs( $options{actual_time} );
- if ( $error ) {
- $error = "Error expiring custnum ". $self->custnum. ": $error";
- if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
- elsif ( $options{fatal} ) { die $error; }
- else { warn $error; }
- }
-
- $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
- if ( $error ) {
- $error = "Error adjourning custnum ". $self->custnum. ": $error";
- if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
- elsif ( $options{fatal} ) { die $error; }
- else { warn $error; }
- }
-
- $job->update_statustext('20,billing packages') if $job;
- $error = $self->bill( %options );
- if ( $error ) {
- $error = "Error billing custnum ". $self->custnum. ": $error";
- if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
- elsif ( $options{fatal} ) { die $error; }
- else { warn $error; }
- }
-
- $job->update_statustext('50,applying payments and credits') if $job;
- $error = $self->apply_payments_and_credits;
- if ( $error ) {
- $error = "Error applying custnum ". $self->custnum. ": $error";
- if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
- elsif ( $options{fatal} ) { die $error; }
- else { warn $error; }
- }
-
- $job->update_statustext('70,running collection events') if $job;
- unless ( $conf->exists('cancelled_cust-noevents')
- && ! $self->num_ncancelled_pkgs
- ) {
- $error = $self->collect( %options );
- if ( $error ) {
- $error = "Error collecting custnum ". $self->custnum. ": $error";
- if ($options{fatal} && $options{fatal} eq 'return') { return $error; }
- elsif ($options{fatal} ) { die $error; }
- else { warn $error; }
- }
- }
- $job->update_statustext('100,finished') if $job;
-
- '';
-
-}
-
-sub cancel_expired_pkgs {
- my ( $self, $time, %options ) = @_;
-
- my @cancel_pkgs = $self->ncancelled_pkgs( {
- 'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
- } );
-
- my @errors = ();
-
- foreach my $cust_pkg ( @cancel_pkgs ) {
- my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
- my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
- 'reason_otaker' => $cpr->otaker
- )
- : ()
- );
- push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
- }
-
- scalar(@errors) ? join(' / ', @errors) : '';
-
-}
-
-sub suspend_adjourned_pkgs {
- my ( $self, $time, %options ) = @_;
-
- my @susp_pkgs = $self->ncancelled_pkgs( {
- 'extra_sql' =>
- " AND ( susp IS NULL OR susp = 0 )
- AND ( ( bill IS NOT NULL AND bill != 0 AND bill < $time )
- OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
- )
- ",
- } );
-
- #only because there's no SQL test for is_prepaid :/
- @susp_pkgs =
- grep { ( $_->part_pkg->is_prepaid
- && $_->bill
- && $_->bill < $time
- )
- || ( $_->adjourn
- && $_->adjourn <= $time
- )
-
- }
- @susp_pkgs;
-
- my @errors = ();
-
- foreach my $cust_pkg ( @susp_pkgs ) {
- my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
- if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
- my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum,
- 'reason_otaker' => $cpr->otaker
- )
- : ()
- );
- push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
- }
-
- scalar(@errors) ? join(' / ', @errors) : '';
-
-}
-
-=item bill OPTIONS
-
-Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
-conjunction with the collect method by calling B<bill_and_collect>.
+Documentation on billing methods has been moved to
+L<FS::cust_main::Billing>.
-If there is an error, returns the error, otherwise returns false.
-
-Options are passed as name-value pairs. Currently available options are:
-
-=over 4
-
-=item resetup
-
-If set true, re-charges setup fees.
-
-=item time
-
-Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
-
- use Date::Parse;
- ...
- $cust_main->bill( 'time' => str2time('April 20th, 2001') );
-
-=item pkg_list
-
-An array ref of specific packages (objects) to attempt billing, instead trying all of them.
-
- $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
-
-=item not_pkgpart
-
-A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
-
-=item invoice_time
-
-Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
-
-=item cancel
-
-This boolean value informs the us that the package is being cancelled. This
-typically might mean not charging the normal recurring fee but only usage
-fees since the last billing. Setup charges may be charged. Not all package
-plans support this feature (they tend to charge 0).
-
-=item invoice_terms
-
-Optional terms to be printed on this invoice. Otherwise, customer-specific
-terms or the default terms are used.
-
-=back
-
-=cut
-
-sub bill {
- my( $self, %options ) = @_;
- return '' if $self->payby eq 'COMP';
- warn "$me bill customer ". $self->custnum. "\n"
- if $DEBUG;
-
- my $time = $options{'time'} || time;
- my $invoice_time = $options{'invoice_time'} || $time;
-
- $options{'not_pkgpart'} ||= {};
- $options{'not_pkgpart'} = { map { $_ => 1 }
- split(/\s*,\s*/, $options{'not_pkgpart'})
- }
- unless ref($options{'not_pkgpart'});
-
- 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;
-
- warn "$me acquiring lock on customer ". $self->custnum. "\n"
- if $DEBUG;
-
- $self->select_for_update; #mutex
-
- warn "$me running pre-bill events for customer ". $self->custnum. "\n"
- if $DEBUG;
-
- my $error = $self->do_cust_event(
- 'debug' => ( $options{'debug'} || 0 ),
- 'time' => $invoice_time,
- 'check_freq' => $options{'check_freq'},
- 'stage' => 'pre-bill',
- );
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
-
- warn "$me done running pre-bill events for customer ". $self->custnum. "\n"
- if $DEBUG;
-
- #keep auto-charge and non-auto-charge line items separate
- my @passes = ( '', 'no_auto' );
-
- my %cust_bill_pkg = map { $_ => [] } @passes;
-
- ###
- # find the packages which are due for billing, find out how much they are
- # & generate invoice database.
- ###
-
- my %total_setup = map { my $z = 0; $_ => \$z; } @passes;
- my %total_recur = map { my $z = 0; $_ => \$z; } @passes;
-
- my %taxlisthash = map { $_ => {} } @passes;
-
- my @precommit_hooks = ();
-
- $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ]; #param checks?
- foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
-
- next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
-
- warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
-
- #? to avoid use of uninitialized value errors... ?
- $cust_pkg->setfield('bill', '')
- unless defined($cust_pkg->bill);
-
- #my $part_pkg = $cust_pkg->part_pkg;
-
- my $real_pkgpart = $cust_pkg->pkgpart;
- my %hash = $cust_pkg->hash;
-
- foreach my $part_pkg ( $cust_pkg->part_pkg->self_and_bill_linked ) {
-
- $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
-
- my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
-
- my $error =
- $self->_make_lines( 'part_pkg' => $part_pkg,
- 'cust_pkg' => $cust_pkg,
- 'precommit_hooks' => \@precommit_hooks,
- 'line_items' => $cust_bill_pkg{$pass},
- 'setup' => $total_setup{$pass},
- 'recur' => $total_recur{$pass},
- 'tax_matrix' => $taxlisthash{$pass},
- 'time' => $time,
- 'real_pkgpart' => $real_pkgpart,
- 'options' => \%options,
- );
- if ($error) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
-
- } #foreach my $part_pkg
-
- } #foreach my $cust_pkg
-
- #if the customer isn't on an automatic payby, everything can go on a single
- #invoice anyway?
- #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
- #merge everything into one list
- #}
-
- foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
-
- my @cust_bill_pkg = @{ $cust_bill_pkg{$pass} };
-
- next unless @cust_bill_pkg; #don't create an invoice w/o line items
-
- if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
- !$conf->exists('postal_invoice-recurring_only')
- )
- {
-
- my $postal_pkg = $self->charge_postal_fee();
- if ( $postal_pkg && !ref( $postal_pkg ) ) {
-
- $dbh->rollback if $oldAutoCommit;
- return "can't charge postal invoice fee for customer ".
- $self->custnum. ": $postal_pkg";
-
- } elsif ( $postal_pkg ) {
-
- my $real_pkgpart = $postal_pkg->pkgpart;
- foreach my $part_pkg ( $postal_pkg->part_pkg->self_and_bill_linked ) {
- my %postal_options = %options;
- delete $postal_options{cancel};
- my $error =
- $self->_make_lines( 'part_pkg' => $part_pkg,
- 'cust_pkg' => $postal_pkg,
- 'precommit_hooks' => \@precommit_hooks,
- 'line_items' => \@cust_bill_pkg,
- 'setup' => $total_setup{$pass},
- 'recur' => $total_recur{$pass},
- 'tax_matrix' => $taxlisthash{$pass},
- 'time' => $time,
- 'real_pkgpart' => $real_pkgpart,
- 'options' => \%postal_options,
- );
- if ($error) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
- }
-
- }
-
- }
-
- my $listref_or_error =
- $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
-
- unless ( ref( $listref_or_error ) ) {
- $dbh->rollback if $oldAutoCommit;
- return $listref_or_error;
- }
-
- foreach my $taxline ( @$listref_or_error ) {
- ${ $total_setup{$pass} } =
- sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
- push @cust_bill_pkg, $taxline;
- }
-
- #add tax adjustments
- warn "adding tax adjustments...\n" if $DEBUG > 2;
- foreach my $cust_tax_adjustment (
- qsearch('cust_tax_adjustment', { 'custnum' => $self->custnum,
- 'billpkgnum' => '',
- }
- )
- ) {
-
- my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
-
- my $itemdesc = $cust_tax_adjustment->taxname;
- $itemdesc = '' if $itemdesc eq 'Tax';
-
- push @cust_bill_pkg, new FS::cust_bill_pkg {
- 'pkgnum' => 0,
- 'setup' => $tax,
- 'recur' => 0,
- 'sdate' => '',
- 'edate' => '',
- 'itemdesc' => $itemdesc,
- 'itemcomment' => $cust_tax_adjustment->comment,
- 'cust_tax_adjustment' => $cust_tax_adjustment,
- #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
- };
-
- }
-
- my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
-
- my @cust_bill = $self->cust_bill;
- my $balance = $self->balance;
- my $previous_balance = scalar(@cust_bill)
- ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
- : 0;
-
- $previous_balance += $cust_bill[$#cust_bill]->charged
- if scalar(@cust_bill);
- #my $balance_adjustments =
- # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
-
- #create the new invoice
- my $cust_bill = new FS::cust_bill ( {
- 'custnum' => $self->custnum,
- '_date' => ( $invoice_time ),
- 'charged' => $charged,
- 'billing_balance' => $balance,
- 'previous_balance' => $previous_balance,
- 'invoice_terms' => $options{'invoice_terms'},
- } );
- $error = $cust_bill->insert;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "can't create invoice for customer #". $self->custnum. ": $error";
- }
-
- foreach my $cust_bill_pkg ( @cust_bill_pkg ) {
- $cust_bill_pkg->invnum($cust_bill->invnum);
- my $error = $cust_bill_pkg->insert;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "can't create invoice line item: $error";
- }
- }
-
- } #foreach my $pass ( keys %cust_bill_pkg )
-
- foreach my $hook ( @precommit_hooks ) {
- eval {
- &{$hook}; #($self) ?
- };
- if ( $@ ) {
- $dbh->rollback if $oldAutoCommit;
- return "$@ running precommit hook $hook\n";
- }
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- ''; #no error
-}
-
-=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
-
-This is a weird one. Perhaps it should not even be exposed.
-
-Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
-Usually used internally by bill method B<bill>.
-
-If there is an error, returns the error, otherwise returns reference to a
-list of line items suitable for insertion.
-
-=over 4
-
-=item LINEITEMREF
+=item REALTIME BILLING METHODS
-An array ref of the line items being billed.
-
-=item TAXHASHREF
-
-A strange beast. The keys to this hash are internal identifiers consisting
-of the name of the tax object type, a space, and its unique identifier ( e.g.
- 'cust_main_county 23' ). The values of the hash are listrefs. The first
-item in the list is the tax object. The remaining items are either line
-items or floating point values (currency amounts).
-
-The taxes are calculated on this entity. Calculated exemption records are
-transferred to the LINEITEMREF items on the assumption that they are related.
-
-Read the source.
-
-=item INVOICE_TIME
-
-This specifies the date appearing on the associated invoice. Some
-jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
-
-=back
-
-=cut
-sub calculate_taxes {
- my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
-
- my @tax_line_items = ();
-
- warn "having a look at the taxes we found...\n" if $DEBUG > 2;
-
- # keys are tax names (as printed on invoices / itemdesc )
- # values are listrefs of taxlisthash keys (internal identifiers)
- my %taxname = ();
-
- # keys are taxlisthash keys (internal identifiers)
- # values are (cumulative) amounts
- my %tax = ();
-
- # keys are taxlisthash keys (internal identifiers)
- # values are listrefs 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
- my %tax_rate_location = ();
-
- foreach my $tax ( keys %$taxlisthash ) {
- my $tax_object = shift @{ $taxlisthash->{$tax} };
- warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
- warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
- my $hashref_or_error =
- $tax_object->taxline( $taxlisthash->{$tax},
- 'custnum' => $self->custnum,
- 'invoice_time' => $invoice_time
- );
- return $hashref_or_error unless ref($hashref_or_error);
-
- unshift @{ $taxlisthash->{$tax} }, $tax_object;
-
- my $name = $hashref_or_error->{'name'};
- my $amount = $hashref_or_error->{'amount'};
-
- #warn "adding $amount as $name\n";
- $taxname{ $name } ||= [];
- push @{ $taxname{ $name } }, $tax;
-
- $tax{ $tax } += $amount;
-
- $tax_location{ $tax } ||= [];
- if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
- push @{ $tax_location{ $tax } },
- {
- 'taxnum' => $tax_object->taxnum,
- 'taxtype' => ref($tax_object),
- 'pkgnum' => $tax_object->get('pkgnum'),
- 'locationnum' => $tax_object->get('locationnum'),
- 'amount' => sprintf('%.2f', $amount ),
- };
- }
-
- $tax_rate_location{ $tax } ||= [];
- if ( ref($tax_object) eq 'FS::tax_rate' ) {
- my $taxratelocationnum =
- $tax_object->tax_rate_location->taxratelocationnum;
- push @{ $tax_rate_location{ $tax } },
- {
- 'taxnum' => $tax_object->taxnum,
- 'taxtype' => ref($tax_object),
- 'amount' => sprintf('%.2f', $amount ),
- 'locationtaxid' => $tax_object->location,
- 'taxratelocationnum' => $taxratelocationnum,
- };
- }
-
- }
-
- #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';
-
- push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
- splice( @{ $_->_cust_tax_exempt_pkg } );
- }
- }
-
- #consolidate and create tax line items
- warn "consolidating and generating...\n" if $DEBUG > 2;
- foreach my $taxname ( keys %taxname ) {
- my $tax = 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};
- push @cust_bill_pkg_tax_location,
- map { new FS::cust_bill_pkg_tax_location $_ }
- @{ $tax_location{ $taxitem } };
- push @cust_bill_pkg_tax_rate_location,
- map { new FS::cust_bill_pkg_tax_rate_location $_ }
- @{ $tax_rate_location{ $taxitem } };
- }
- next unless $tax;
-
- $tax = sprintf('%.2f', $tax );
-
- my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
- 'disabled' => '',
- },
- );
-
- my @display = ();
- if ( $pkg_category and
- $conf->config('invoice_latexsummary') ||
- $conf->config('invoice_htmlsummary')
- )
- {
-
- my %hash = ( 'section' => $pkg_category->categoryname );
- push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
-
- }
-
- push @tax_line_items, new FS::cust_bill_pkg {
- 'pkgnum' => 0,
- 'setup' => $tax,
- 'recur' => 0,
- 'sdate' => '',
- 'edate' => '',
- 'itemdesc' => $taxname,
- 'display' => \@display,
- 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
- 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
- };
-
- }
-
- \@tax_line_items;
-}
-
-sub _make_lines {
- my ($self, %params) = @_;
-
- my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
- my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
- my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
- my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
- my $total_setup = $params{setup} or die "no setup accumulator specified";
- my $total_recur = $params{recur} or die "no recur accumulator specified";
- my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
- my $time = $params{'time'} or die "no time specified";
- my (%options) = %{$params{options}};
-
- my $dbh = dbh;
- my $real_pkgpart = $params{real_pkgpart};
- my %hash = $cust_pkg->hash;
- my $old_cust_pkg = new FS::cust_pkg \%hash;
-
- my @details = ();
- my @discounts = ();
- my $lineitems = 0;
-
- $cust_pkg->pkgpart($part_pkg->pkgpart);
-
- ###
- # bill setup
- ###
-
- my $setup = 0;
- my $unitsetup = 0;
- if ( $options{'resetup'}
- || ( ! $cust_pkg->setup
- && ( ! $cust_pkg->start_date
- || $cust_pkg->start_date <= $time
- )
- && ( ! $conf->exists('disable_setup_suspended_pkgs')
- || ( $conf->exists('disable_setup_suspended_pkgs') &&
- ! $cust_pkg->getfield('susp')
- )
- )
- )
- )
- {
-
- warn " bill setup\n" if $DEBUG > 1;
- $lineitems++;
-
- $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
- return "$@ running calc_setup for $cust_pkg\n"
- if $@;
-
- $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
-
- $cust_pkg->setfield('setup', $time)
- unless $cust_pkg->setup;
- #do need it, but it won't get written to the db
- #|| $cust_pkg->pkgpart != $real_pkgpart;
-
- $cust_pkg->setfield('start_date', '')
- if $cust_pkg->start_date;
-
- }
-
- ###
- # bill recurring fee
- ###
-
- #XXX unit stuff here too
- my $recur = 0;
- my $unitrecur = 0;
- my $sdate;
- if ( ! $cust_pkg->get('susp')
- and ! $cust_pkg->get('start_date')
- and ( $part_pkg->getfield('freq') ne '0'
- && ( $cust_pkg->getfield('bill') || 0 ) <= $time
- )
- || ( $part_pkg->plan eq 'voip_cdr'
- && $part_pkg->option('bill_every_call')
- )
- || ( $options{cancel} )
- ) {
-
- # XXX should this be a package event? probably. events are called
- # at collection time at the moment, though...
- $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
- if $part_pkg->can('reset_usage');
- #don't want to reset usage just cause we want a line item??
- #&& $part_pkg->pkgpart == $real_pkgpart;
-
- warn " bill recur\n" if $DEBUG > 1;
- $lineitems++;
-
- # XXX shared with $recur_prog
- $sdate = ( $options{cancel} ? $cust_pkg->last_bill : $cust_pkg->bill )
- || $cust_pkg->setup
- || $time;
-
- #over two params! lets at least switch to a hashref for the rest...
- my $increment_next_bill = ( $part_pkg->freq ne '0'
- && ( $cust_pkg->getfield('bill') || 0 ) <= $time
- && !$options{cancel}
- );
- my %param = ( 'precommit_hooks' => $precommit_hooks,
- 'increment_next_bill' => $increment_next_bill,
- 'discounts' => \@discounts,
- );
-
- my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
- $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
- return "$@ running $method for $cust_pkg\n"
- if ( $@ );
-
- if ( $increment_next_bill ) {
-
- my $next_bill = $part_pkg->add_freq($sdate);
- return "unparsable frequency: ". $part_pkg->freq
- if $next_bill == -1;
-
- #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
- # only for figuring next bill date, nothing else, so, reset $sdate again
- # here
- $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
- #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
- $cust_pkg->last_bill($sdate);
-
- $cust_pkg->setfield('bill', $next_bill );
-
- }
-
- }
-
- warn "\$setup is undefined" unless defined($setup);
- warn "\$recur is undefined" unless defined($recur);
- warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
-
- ###
- # If there's line items, create em cust_bill_pkg records
- # If $cust_pkg has been modified, update it (if we're a real pkgpart)
- ###
-
- if ( $lineitems ) {
-
- if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
- # hmm.. and if just the options are modified in some weird price plan?
-
- warn " package ". $cust_pkg->pkgnum. " modified; updating\n"
- if $DEBUG >1;
-
- my $error = $cust_pkg->replace( $old_cust_pkg,
- 'options' => { $cust_pkg->options },
- );
- return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
- if $error; #just in case
- }
-
- $setup = sprintf( "%.2f", $setup );
- $recur = sprintf( "%.2f", $recur );
- if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
- return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
- }
- if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
- return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
- }
-
- if ( $setup != 0 || $recur != 0 ) {
-
- warn " charges (setup=$setup, recur=$recur); adding line items\n"
- if $DEBUG > 1;
-
- my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I');
- if ( $DEBUG > 1 ) {
- warn " adding customer package invoice detail: $_\n"
- foreach @cust_pkg_detail;
- }
- push @details, @cust_pkg_detail;
-
- my $cust_bill_pkg = new FS::cust_bill_pkg {
- 'pkgnum' => $cust_pkg->pkgnum,
- 'setup' => $setup,
- 'unitsetup' => $unitsetup,
- 'recur' => $recur,
- 'unitrecur' => $unitrecur,
- 'quantity' => $cust_pkg->quantity,
- 'details' => \@details,
- 'discounts' => \@discounts,
- 'hidden' => $part_pkg->hidden,
- };
-
- if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
- $cust_bill_pkg->sdate( $hash{last_bill} );
- $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
- $cust_bill_pkg->edate( $time ) if $options{cancel};
- } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
- $cust_bill_pkg->sdate( $sdate );
- $cust_bill_pkg->edate( $cust_pkg->bill );
- #$cust_bill_pkg->edate( $time ) if $options{cancel};
- }
-
- $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
- unless $part_pkg->pkgpart == $real_pkgpart;
-
- $$total_setup += $setup;
- $$total_recur += $recur;
-
- ###
- # handle taxes
- ###
-
- my $error =
- $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
- return $error if $error;
-
- push @$cust_bill_pkgs, $cust_bill_pkg;
-
- } #if $setup != 0 || $recur != 0
-
- } #if $line_items
-
- '';
-
-}
-
-sub _handle_taxes {
- my $self = shift;
- my $part_pkg = shift;
- my $taxlisthash = shift;
- my $cust_bill_pkg = shift;
- my $cust_pkg = shift;
- my $invoice_time = shift;
- my $real_pkgpart = shift;
- my $options = shift;
-
- my %cust_bill_pkg = ();
- my %taxes = ();
-
- my @classes;
- #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
- push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
- push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
- push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
-
- if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
-
- if ( $conf->exists('enable_taxproducts')
- && ( scalar($part_pkg->part_pkg_taxoverride)
- || $part_pkg->has_taxproduct
- )
- )
- {
-
- if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
- return "fatal: Can't (yet) use tax-pkg_address with taxproducts";
- }
-
- foreach my $class (@classes) {
- my $err_or_ref = $self->_gather_taxes( $part_pkg, $class );
- 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, '' );
- return $err_or_ref unless ref($err_or_ref);
- $taxes{''} = $err_or_ref;
- }
-
- } else {
-
- my @loc_keys = qw( city county state country );
- my %taxhash;
- if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
- my $cust_location = $cust_pkg->cust_location;
- %taxhash = map { $_ => $cust_location->$_() } @loc_keys;
- } else {
- my $prefix =
- ( $conf->exists('tax-ship_address') && length($self->ship_last) )
- ? 'ship_'
- : '';
- %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
- }
-
- $taxhash{'taxclass'} = $part_pkg->taxclass;
-
- my @taxes = ();
- my %taxhash_elim = %taxhash;
- my @elim = qw( city county state );
- do {
-
- #first try a match with taxclass
- @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
-
- if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
- #then try a match without taxclass
- my %no_taxclass = %taxhash_elim;
- $no_taxclass{ 'taxclass' } = '';
- @taxes = qsearch( 'cust_main_county', \%no_taxclass );
- }
-
- $taxhash_elim{ shift(@elim) } = '';
-
- } while ( !scalar(@taxes) && scalar(@elim) );
-
- @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
- @taxes
- if $self->cust_main_exemption; #just to be safe
-
- if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
- foreach (@taxes) {
- $_->set('pkgnum', $cust_pkg->pkgnum );
- $_->set('locationnum', $cust_pkg->locationnum );
- }
- }
-
- $taxes{''} = [ @taxes ];
- $taxes{'setup'} = [ @taxes ];
- $taxes{'recur'} = [ @taxes ];
- $taxes{$_} = [ @taxes ] foreach (@classes);
-
- # # maybe eliminate this entirely, along with all the 0% records
- # unless ( @taxes ) {
- # return
- # "fatal: can't find tax rate for state/county/country/taxclass ".
- # join('/', map $taxhash{$_}, qw(state county country taxclass) );
- # }
-
- } #if $conf->exists('enable_taxproducts') ...
-
- }
-
- my @display = ();
- my $separate = $conf->exists('separate_usage');
- my $usage_mandate = $cust_pkg->part_pkg->option('usage_mandate', 'Hush!');
- if ( $separate || $cust_bill_pkg->hidden || $usage_mandate ) {
-
- my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
- my %hash = $cust_bill_pkg->hidden # maybe for all bill linked?
- ? ( 'section' => $temp_pkg->part_pkg->categoryname )
- : ();
-
- my $section = $cust_pkg->part_pkg->option('usage_section', 'Hush!');
- my $summary = $cust_pkg->part_pkg->option('summarize_usage', 'Hush!');
- if ( $separate ) {
- push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
- push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
- } else {
- push @display, new FS::cust_bill_pkg_display
- { type => '',
- %hash,
- ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
- };
- }
-
- if ($separate && $section && $summary) {
- push @display, new FS::cust_bill_pkg_display { type => 'U',
- summary => 'Y',
- %hash,
- };
- }
- if ($usage_mandate || $section && $summary) {
- $hash{post_total} = 'Y';
- }
-
- $hash{section} = $section if ($separate || $usage_mandate);
- push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
-
- }
- $cust_bill_pkg->set('display', \@display);
-
- my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
- foreach my $key (keys %tax_cust_bill_pkg) {
- my @taxes = @{ $taxes{$key} || [] };
- my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
-
- my %localtaxlisthash = ();
- foreach my $tax ( @taxes ) {
-
- my $taxname = ref( $tax ). ' '. $tax->taxnum;
-# $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
-# ' locationnum'. $cust_pkg->locationnum
-# if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
-
- $taxlisthash->{ $taxname } ||= [ $tax ];
- push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
-
- $localtaxlisthash{ $taxname } ||= [ $tax ];
- push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg;
-
- }
-
- warn "finding taxed taxes...\n" if $DEBUG > 2;
- foreach my $tax ( keys %localtaxlisthash ) {
- my $tax_object = shift @{ $localtaxlisthash{$tax} };
- warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
- if $DEBUG > 2;
- next unless $tax_object->can('tax_on_tax');
-
- foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
- my $totname = ref( $tot ). ' '. $tot->taxnum;
-
- warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
- if $DEBUG > 2;
- next unless exists( $localtaxlisthash{ $totname } ); # only increase
- # existing taxes
- warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
- my $hashref_or_error =
- $tax_object->taxline( $localtaxlisthash{$tax},
- 'custnum' => $self->custnum,
- 'invoice_time' => $invoice_time,
- );
- return $hashref_or_error
- unless ref($hashref_or_error);
-
- $taxlisthash->{ $totname } ||= [ $tot ];
- push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount};
-
- }
- }
-
- }
-
- '';
-}
-
-sub _gather_taxes {
- my $self = shift;
- my $part_pkg = shift;
- my $class = shift;
-
- my @taxes = ();
- my $geocode = $self->geocode('cch');
-
- my @taxclassnums = map { $_->taxclassnum }
- $part_pkg->part_pkg_taxoverride($class);
-
- unless (@taxclassnums) {
- @taxclassnums = map { $_->taxclassnum }
- grep { $_->taxable eq 'Y' }
- $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
- }
- warn "Found taxclassnum values of ". join(',', @taxclassnums)
- if $DEBUG;
-
- my $extra_sql =
- "AND (".
- join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
-
- @taxes = qsearch({ 'table' => 'tax_rate',
- 'hashref' => { 'geocode' => $geocode, },
- 'extra_sql' => $extra_sql,
- })
- if scalar(@taxclassnums);
-
- warn "Found taxes ".
- join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
- if $DEBUG;
-
- [ @taxes ];
-
-}
-
-=item collect [ HASHREF | OPTION => VALUE ... ]
-
-(Attempt to) collect money for this customer's outstanding invoices (see
-L<FS::cust_bill>). Usually used after the bill method.
-
-Actions are now triggered by billing events; see L<FS::part_event> and the
-billing events web interface. Old-style invoice events (see
-L<FS::part_bill_event>) have been deprecated.
-
-If there is an error, returns the error, otherwise returns false.
-
-Options are passed as name-value pairs.
-
-Currently available options are:
-
-=over 4
-
-=item invoice_time
-
-Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=item retry
-
-Retry card/echeck/LEC transactions even when not scheduled by invoice events.
-
-=item check_freq
-
-"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
-
-=item quiet
-
-set true to surpress email card/ACH decline notices.
-
-=item debug
-
-Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=back
-
-# =item payby
-#
-# allows for one time override of normal customer billing method
-
-=cut
-
-sub collect {
- my( $self, %options ) = @_;
- my $invoice_time = $options{'invoice_time'} || time;
-
- #put below somehow?
- 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;
-
- $self->select_for_update; #mutex
-
- if ( $DEBUG ) {
- my $balance = $self->balance;
- warn "$me collect customer ". $self->custnum. ": balance $balance\n"
- }
-
- if ( exists($options{'retry_card'}) ) {
- carp 'retry_card option passed to collect is deprecated; use retry';
- $options{'retry'} ||= $options{'retry_card'};
- }
- if ( exists($options{'retry'}) && $options{'retry'} ) {
- my $error = $self->retry_realtime;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
- #never want to roll back an event just because it returned an error
- local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
-
- $self->do_cust_event(
- 'debug' => ( $options{'debug'} || 0 ),
- 'time' => $invoice_time,
- 'check_freq' => $options{'check_freq'},
- 'stage' => 'collect',
- );
-
-}
-
-=item do_cust_event [ HASHREF | OPTION => VALUE ... ]
-
-Runs billing events; see L<FS::part_event> and the billing events web
-interface.
-
-If there is an error, returns the error, otherwise returns false.
-
-Options are passed as name-value pairs.
-
-Currently available options are:
-
-=over 4
-
-=item time
-
-Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
-
-=item check_freq
-
-"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
-
-=item stage
-
-"collect" (the default) or "pre-bill"
-
-=item quiet
-
-set true to surpress email card/ACH decline notices.
-
-=item debug
-
-Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=cut
-
-# =item payby
-#
-# allows for one time override of normal customer billing method
-
-# =item retry
-#
-# Retry card/echeck/LEC transactions even when not scheduled by invoice events.
-
-sub do_cust_event {
- my( $self, %options ) = @_;
- my $time = $options{'time'} || time;
-
- #put below somehow?
- 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;
-
- $self->select_for_update; #mutex
-
- if ( $DEBUG ) {
- my $balance = $self->balance;
- warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
- }
-
-# if ( exists($options{'retry_card'}) ) {
-# carp 'retry_card option passed to collect is deprecated; use retry';
-# $options{'retry'} ||= $options{'retry_card'};
-# }
-# if ( exists($options{'retry'}) && $options{'retry'} ) {
-# my $error = $self->retry_realtime;
-# if ( $error ) {
-# $dbh->rollback if $oldAutoCommit;
-# return $error;
-# }
-# }
-
- # false laziness w/pay_batch::import_results
-
- my $due_cust_event = $self->due_cust_event(
- 'debug' => ( $options{'debug'} || 0 ),
- 'time' => $time,
- 'check_freq' => $options{'check_freq'},
- 'stage' => ( $options{'stage'} || 'collect' ),
- );
- unless( ref($due_cust_event) ) {
- $dbh->rollback if $oldAutoCommit;
- return $due_cust_event;
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- #never want to roll back an event just because it or a different one
- # returned an error
- local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
-
- foreach my $cust_event ( @$due_cust_event ) {
-
- #XXX lock event
-
- #re-eval event conditions (a previous event could have changed things)
- unless ( $cust_event->test_conditions( 'time' => $time ) ) {
- #don't leave stray "new/locked" records around
- my $error = $cust_event->delete;
- return $error if $error;
- next;
- }
-
- {
- local $realtime_bop_decline_quiet = 1 if $options{'quiet'};
- warn " running cust_event ". $cust_event->eventnum. "\n"
- if $DEBUG > 1;
-
- #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
- if ( my $error = $cust_event->do_event() ) {
- #XXX wtf is this? figure out a proper dealio with return value
- #from do_event
- return $error;
- }
- }
-
- }
-
- '';
-
-}
-
-=item due_cust_event [ HASHREF | OPTION => VALUE ... ]
-
-Inserts database records for and returns an ordered listref of new events due
-for this customer, as FS::cust_event objects (see L<FS::cust_event>). If no
-events are due, an empty listref is returned. If there is an error, returns a
-scalar error message.
-
-To actually run the events, call each event's test_condition method, and if
-still true, call the event's do_event method.
-
-Options are passed as a hashref or as a list of name-value pairs. Available
-options are:
-
-=over 4
-
-=item check_freq
-
-Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
-
-=item stage
-
-"collect" (the default) or "pre-bill"
-
-=item time
-
-"Current time" for the events.
-
-=item debug
-
-Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
-
-=item eventtable
-
-Only return events for the specified eventtable (by default, events of all eventtables are returned)
-
-=item objects
-
-Explicitly pass the objects to be tested (typically used with eventtable).
-
-=item testonly
-
-Set to true to return the objects, but not actually insert them into the
-database.
-
-=back
-
-=cut
-
-sub due_cust_event {
- my $self = shift;
- my %opt = ref($_[0]) ? %{ $_[0] } : @_;
-
- #???
- #my $DEBUG = $opt{'debug'}
- local($DEBUG) = $opt{'debug'}
- if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
-
- warn "$me due_cust_event called with options ".
- join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
- if $DEBUG;
-
- $opt{'time'} ||= time;
-
- 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;
-
- $self->select_for_update #mutex
- unless $opt{testonly};
-
- ###
- # find possible events (initial search)
- ###
-
- my @cust_event = ();
-
- my @eventtable = $opt{'eventtable'}
- ? ( $opt{'eventtable'} )
- : FS::part_event->eventtables_runorder;
-
- foreach my $eventtable ( @eventtable ) {
-
- my @objects;
- if ( $opt{'objects'} ) {
-
- @objects = @{ $opt{'objects'} };
-
- } else {
-
- #my @objects = $self->eventtable(); # sub cust_main { @{ [ $self ] }; }
- @objects = ( $eventtable eq 'cust_main' )
- ? ( $self )
- : ( $self->$eventtable() );
-
- }
-
- my @e_cust_event = ();
-
- my $cross = "CROSS JOIN $eventtable";
- $cross .= ' LEFT JOIN cust_main USING ( custnum )'
- unless $eventtable eq 'cust_main';
-
- foreach my $object ( @objects ) {
-
- #this first search uses the condition_sql magic for optimization.
- #the more possible events we can eliminate in this step the better
-
- my $cross_where = '';
- my $pkey = $object->primary_key;
- $cross_where = "$eventtable.$pkey = ". $object->$pkey();
-
- my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
- my $extra_sql =
- FS::part_event_condition->where_conditions_sql( $eventtable,
- 'time'=>$opt{'time'}
- );
- my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
-
- $extra_sql = "AND $extra_sql" if $extra_sql;
-
- #here is the agent virtualization
- $extra_sql .= " AND ( part_event.agentnum IS NULL
- OR part_event.agentnum = ". $self->agentnum. ' )';
-
- $extra_sql .= " $order";
-
- warn "searching for events for $eventtable ". $object->$pkey. "\n"
- if $opt{'debug'} > 2;
- my @part_event = qsearch( {
- 'debug' => ( $opt{'debug'} > 3 ? 1 : 0 ),
- 'select' => 'part_event.*',
- 'table' => 'part_event',
- 'addl_from' => "$cross $join",
- 'hashref' => { 'check_freq' => ( $opt{'check_freq'} || '1d' ),
- 'eventtable' => $eventtable,
- 'disabled' => '',
- },
- 'extra_sql' => "AND $cross_where $extra_sql",
- } );
-
- if ( $DEBUG > 2 ) {
- my $pkey = $object->primary_key;
- warn " ". scalar(@part_event).
- " possible events found for $eventtable ". $object->$pkey(). "\n";
- }
-
- push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
-
- }
-
- warn " ". scalar(@e_cust_event).
- " subtotal possible cust events found for $eventtable\n"
- if $DEBUG > 1;
-
- push @cust_event, @e_cust_event;
-
- }
-
- warn " ". scalar(@cust_event).
- " total possible cust events found in initial search\n"
- if $DEBUG; # > 1;
-
-
- ##
- # test stage
- ##
-
- $opt{stage} ||= 'collect';
- @cust_event =
- grep { my $stage = $_->part_event->event_stage;
- $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
- }
- @cust_event;
-
- ##
- # test conditions
- ##
-
- my %unsat = ();
-
- @cust_event = grep $_->test_conditions( 'time' => $opt{'time'},
- 'stats_hashref' => \%unsat ),
- @cust_event;
-
- warn " ". scalar(@cust_event). " cust events left satisfying conditions\n"
- if $DEBUG; # > 1;
-
- warn " invalid conditions not eliminated with condition_sql:\n".
- join('', map " $_: ".$unsat{$_}."\n", keys %unsat )
- if keys %unsat && $DEBUG; # > 1;
-
- ##
- # insert
- ##
-
- unless( $opt{testonly} ) {
- foreach my $cust_event ( @cust_event ) {
-
- my $error = $cust_event->insert();
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
- }
-
- }
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
- ##
- # return
- ##
-
- warn " returning events: ". Dumper(@cust_event). "\n"
- if $DEBUG > 2;
-
- \@cust_event;
-
-}
-
-=item retry_realtime
-
-Schedules realtime / batch credit card / electronic check / LEC billing
-events for for retry. Useful if card information has changed or manual
-retry is desired. The 'collect' method must be called to actually retry
-the transaction.
-
-Implementation details: For either this customer, or for each of this
-customer's open invoices, changes the status of the first "done" (with
-statustext error) realtime processing event to "failed".
-
-=cut
-
-sub retry_realtime {
- 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;
-
- #a little false laziness w/due_cust_event (not too bad, really)
-
- my $join = FS::part_event_condition->join_conditions_sql;
- my $order = FS::part_event_condition->order_conditions_sql;
- my $mine =
- '( '
- . join ( ' OR ' , map {
- "( part_event.eventtable = " . dbh->quote($_)
- . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
- } FS::part_event->eventtables)
- . ') ';
-
- #here is the agent virtualization
- my $agent_virt = " ( part_event.agentnum IS NULL
- OR part_event.agentnum = ". $self->agentnum. ' )';
-
- #XXX this shouldn't be hardcoded, actions should declare it...
- my @realtime_events = qw(
- cust_bill_realtime_card
- cust_bill_realtime_check
- cust_bill_realtime_lec
- cust_bill_batch
- );
-
- my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
- @realtime_events
- ).
- ' ) ';
-
- my @cust_event = qsearchs({
- 'table' => 'cust_event',
- 'select' => 'cust_event.*',
- 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
- 'hashref' => { 'status' => 'done' },
- 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
- " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
- });
-
- my %seen_invnum = ();
- foreach my $cust_event (@cust_event) {
-
- #max one for the customer, one for each open invoice
- my $cust_X = $cust_event->cust_X;
- next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
- ? $cust_X->invnum
- : 0
- }++
- or $cust_event->part_event->eventtable eq 'cust_bill'
- && ! $cust_X->owed;
-
- my $error = $cust_event->retry;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "error scheduling event for retry: $error";
- }
-
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- '';
-
-}
-
-
-=cut
-
-=item realtime_collect [ OPTION => VALUE ... ]
-
-Runs a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime
-gateway. See L<http://420.am/business-onlinepayment> and
-L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
-
-On failure returns an error message.
-
-Returns false or a hashref upon success. The hashref contains keys popup_url reference, and collectitems. The first is a URL to which a browser should be redirected for completion of collection. The second is a reference id for the transaction suitable for the end user. The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
-
-Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
-
-I<method> is one of: I<CC>, I<ECHECK> and I<LEC>. If none is specified
-then it is deduced from the customer record.
-
-If no I<amount> is specified, then the customer balance is used.
-
-The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
-I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
-if set, will override the value from the customer record.
-
-I<description> is a free-text field passed to the gateway. It defaults to
-the value defined by the business-onlinepayment-description configuration
-option, or "Internet services" if that is unset.
-
-If an I<invnum> is specified, this payment (if successful) is applied to the
-specified invoice. If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method or set the I<apply> option.
-
-I<apply> can be set to true to apply a resulting payment.
-
-I<quiet> can be set true to surpress email decline notices.
-
-I<paynum_ref> can be set to a scalar reference. It will be filled in with the
-resulting paynum, if any.
-
-I<payunique> is a unique identifier for this payment.
-
-I<session_id> is a session identifier associated with this payment.
-
-I<depend_jobnum> allows payment capture to unlock export jobs
-
-=cut
-
-sub realtime_collect {
- my( $self, %options ) = @_;
-
- if ( $DEBUG ) {
- warn "$me realtime_collect:\n";
- warn " $_ => $options{$_}\n" foreach keys %options;
- }
-
- $options{amount} = $self->balance unless exists( $options{amount} );
- $options{method} = FS::payby->payby2bop($self->payby)
- unless exists( $options{method} );
-
- return $self->realtime_bop({%options});
-
-}
-
-=item realtime_bop { [ ARG => VALUE ... ] }
-
-Runs a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment realtime gateway. See
-L<http://420.am/business-onlinepayment> for supported gateways.
-
-Required arguments in the hashref are I<method>, and I<amount>
-
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
-
-Available optional arguments are: I<description>, I<invnum>, I<apply>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
-
-The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
-I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
-if set, will override the value from the customer record.
-
-I<description> is a free-text field passed to the gateway. It defaults to
-the value defined by the business-onlinepayment-description configuration
-option, or "Internet services" if that is unset.
-
-If an I<invnum> is specified, this payment (if successful) is applied to the
-specified invoice. If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method or set the I<apply> option.
-
-I<apply> can be set to true to apply a resulting payment.
-
-I<quiet> can be set true to surpress email decline notices.
-
-I<paynum_ref> can be set to a scalar reference. It will be filled in with the
-resulting paynum, if any.
-
-I<payunique> is a unique identifier for this payment.
-
-I<session_id> is a session identifier associated with this payment.
-
-I<depend_jobnum> allows payment capture to unlock export jobs
-
-(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
-
-=cut
-
-# some helper routines
-sub _bop_recurring_billing {
- my( $self, %opt ) = @_;
-
- my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
-
- if ( defined($method) && $method eq 'transaction_is_recur' ) {
-
- return 1 if $opt{'trans_is_recur'};
-
- } else {
-
- my %hash = ( 'custnum' => $self->custnum,
- 'payby' => 'CARD',
- );
-
- return 1
- if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
- || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
- $opt{'payinfo'} )
- } );
-
- }
-
- return 0;
-
-}
-
-sub _payment_gateway {
- my ($self, $options) = @_;
-
- $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
- unless exists($options->{payment_gateway});
-
- $options->{payment_gateway};
-}
-
-sub _bop_auth {
- my ($self, $options) = @_;
-
- (
- 'login' => $options->{payment_gateway}->gateway_username,
- 'password' => $options->{payment_gateway}->gateway_password,
- );
-}
-
-sub _bop_options {
- my ($self, $options) = @_;
-
- $options->{payment_gateway}->gatewaynum
- ? $options->{payment_gateway}->options
- : @{ $options->{payment_gateway}->get('options') };
-
-}
-
-sub _bop_defaults {
- my ($self, $options) = @_;
-
- unless ( $options->{'description'} ) {
- if ( $conf->exists('business-onlinepayment-description') ) {
- my $dtempl = $conf->config('business-onlinepayment-description');
-
- my $agent = $self->agent->agent;
- #$pkgs... not here
- $options->{'description'} = eval qq("$dtempl");
- } else {
- $options->{'description'} = 'Internet services';
- }
- }
-
- $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
- $options->{invnum} ||= '';
- $options->{payname} = $self->payname unless exists( $options->{payname} );
-}
-
-sub _bop_content {
- my ($self, $options) = @_;
- my %content = ();
-
- my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
- $content{customer_ip} = $payip if length($payip);
-
- $content{invoice_number} = $options->{'invnum'}
- if exists($options->{'invnum'}) && length($options->{'invnum'});
-
- $content{email_customer} =
- ( $conf->exists('business-onlinepayment-email_customer')
- || $conf->exists('business-onlinepayment-email-override') );
-
- my ($payname, $payfirst, $paylast);
- if ( $options->{payname} && $options->{method} ne 'ECHECK' ) {
- ($payname = $options->{payname}) =~
- /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
- or return "Illegal payname $payname";
- ($payfirst, $paylast) = ($1, $2);
- } else {
- $payfirst = $self->getfield('first');
- $paylast = $self->getfield('last');
- $payname = "$payfirst $paylast";
- }
-
- $content{last_name} = $paylast;
- $content{first_name} = $payfirst;
-
- $content{name} = $payname;
-
- $content{address} = exists($options->{'address1'})
- ? $options->{'address1'}
- : $self->address1;
- my $address2 = exists($options->{'address2'})
- ? $options->{'address2'}
- : $self->address2;
- $content{address} .= ", ". $address2 if length($address2);
-
- $content{city} = exists($options->{city})
- ? $options->{city}
- : $self->city;
- $content{state} = exists($options->{state})
- ? $options->{state}
- : $self->state;
- $content{zip} = exists($options->{zip})
- ? $options->{'zip'}
- : $self->zip;
- $content{country} = exists($options->{country})
- ? $options->{country}
- : $self->country;
-
- $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
- $content{phone} = $self->daytime || $self->night;
-
- \%content;
-}
-
-my %bop_method2payby = (
- 'CC' => 'CARD',
- 'ECHECK' => 'CHEK',
- 'LEC' => 'LECB',
-);
-
-sub realtime_bop {
- my $self = shift;
-
- my %options = ();
- if (ref($_[0]) eq 'HASH') {
- %options = %{$_[0]};
- } else {
- my ( $method, $amount ) = ( shift, shift );
- %options = @_;
- $options{method} = $method;
- $options{amount} = $amount;
- }
-
- if ( $DEBUG ) {
- warn "$me realtime_bop (new): $options{method} $options{amount}\n";
- warn " $_ => $options{$_}\n" foreach keys %options;
- }
-
- return $self->fake_bop(%options) if $options{'fake'};
-
- $self->_bop_defaults(\%options);
-
- ###
- # set trans_is_recur based on invnum if there is one
- ###
-
- my $trans_is_recur = 0;
- if ( $options{'invnum'} ) {
-
- my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
- die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
-
- my @part_pkg =
- map { $_->part_pkg }
- grep { $_ }
- map { $_->cust_pkg }
- $cust_bill->cust_bill_pkg;
-
- $trans_is_recur = 1
- if grep { $_->freq ne '0' } @part_pkg;
-
- }
-
- ###
- # select a gateway
- ###
-
- my $payment_gateway = $self->_payment_gateway( \%options );
- my $namespace = $payment_gateway->gateway_namespace;
-
- eval "use $namespace";
- die $@ if $@;
-
- ###
- # check for banned credit card/ACH
- ###
-
- my $ban = qsearchs('banned_pay', {
- 'payby' => $bop_method2payby{$options{method}},
- 'payinfo' => md5_base64($options{payinfo}),
- } );
- return "Banned credit card" if $ban;
-
- ###
- # massage data
- ###
-
- my $bop_content = $self->_bop_content(\%options);
- return $bop_content unless ref($bop_content);
-
- my @invoicing_list = $self->invoicing_list_emailonly;
- if ( $conf->exists('emailinvoiceautoalways')
- || $conf->exists('emailinvoiceauto') && ! @invoicing_list
- || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
- push @invoicing_list, $self->all_emails;
- }
-
- my $email = ($conf->exists('business-onlinepayment-email-override'))
- ? $conf->config('business-onlinepayment-email-override')
- : $invoicing_list[0];
-
- my $paydate = '';
- my %content = ();
- if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
-
- $content{card_number} = $options{payinfo};
- $paydate = exists($options{'paydate'})
- ? $options{'paydate'}
- : $self->paydate;
- $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
- $content{expiration} = "$2/$1";
-
- my $paycvv = exists($options{'paycvv'})
- ? $options{'paycvv'}
- : $self->paycvv;
- $content{cvv2} = $paycvv
- if length($paycvv);
-
- my $paystart_month = exists($options{'paystart_month'})
- ? $options{'paystart_month'}
- : $self->paystart_month;
-
- my $paystart_year = exists($options{'paystart_year'})
- ? $options{'paystart_year'}
- : $self->paystart_year;
-
- $content{card_start} = "$paystart_month/$paystart_year"
- if $paystart_month && $paystart_year;
-
- my $payissue = exists($options{'payissue'})
- ? $options{'payissue'}
- : $self->payissue;
- $content{issue_number} = $payissue if $payissue;
-
- if ( $self->_bop_recurring_billing( 'payinfo' => $options{'payinfo'},
- 'trans_is_recur' => $trans_is_recur,
- )
- )
- {
- $content{recurring_billing} = 'YES';
- $content{acct_code} = 'rebill'
- if $conf->exists('credit_card-recurring_billing_acct_code');
- }
-
- } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
- ( $content{account_number}, $content{routing_code} ) =
- split('@', $options{payinfo});
- $content{bank_name} = $options{payname};
- $content{bank_state} = exists($options{'paystate'})
- ? $options{'paystate'}
- : $self->getfield('paystate');
- $content{account_type} = exists($options{'paytype'})
- ? uc($options{'paytype'}) || 'CHECKING'
- : uc($self->getfield('paytype')) || 'CHECKING';
- $content{account_name} = $self->getfield('first'). ' '.
- $self->getfield('last');
-
- $content{customer_org} = $self->company ? 'B' : 'I';
- $content{state_id} = exists($options{'stateid'})
- ? $options{'stateid'}
- : $self->getfield('stateid');
- $content{state_id_state} = exists($options{'stateid_state'})
- ? $options{'stateid_state'}
- : $self->getfield('stateid_state');
- $content{customer_ssn} = exists($options{'ss'})
- ? $options{'ss'}
- : $self->ss;
- } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) {
- $content{phone} = $options{payinfo};
- } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
- #move along
- } else {
- #die an evil death
- }
-
- ###
- # run transaction(s)
- ###
-
- my $balance = exists( $options{'balance'} )
- ? $options{'balance'}
- : $self->balance;
-
- $self->select_for_update; #mutex ... just until we get our pending record in
-
- #the checks here are intended to catch concurrent payments
- #double-form-submission prevention is taken care of in cust_pay_pending::check
-
- #check the balance
- return "The customer's balance has changed; $options{method} transaction aborted."
- if $self->balance < $balance;
- #&& $self->balance < $options{amount}; #might as well anyway?
-
- #also check and make sure there aren't *other* pending payments for this cust
-
- my @pending = qsearch('cust_pay_pending', {
- 'custnum' => $self->custnum,
- 'status' => { op=>'!=', value=>'done' }
- });
- return "A payment is already being processed for this customer (".
- join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
- "); $options{method} transaction aborted."
- if scalar(@pending);
-
- #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
-
- my $cust_pay_pending = new FS::cust_pay_pending {
- 'custnum' => $self->custnum,
- #'invnum' => $options{'invnum'},
- 'paid' => $options{amount},
- '_date' => '',
- 'payby' => $bop_method2payby{$options{method}},
- 'payinfo' => $options{payinfo},
- 'paydate' => $paydate,
- 'recurring_billing' => $content{recurring_billing},
- 'pkgnum' => $options{'pkgnum'},
- 'status' => 'new',
- 'gatewaynum' => $payment_gateway->gatewaynum || '',
- 'session_id' => $options{session_id} || '',
- 'jobnum' => $options{depend_jobnum} || '',
- };
- $cust_pay_pending->payunique( $options{payunique} )
- if defined($options{payunique}) && length($options{payunique});
- my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
- return $cpp_new_err if $cpp_new_err;
-
- my( $action1, $action2 ) =
- split( /\s*\,\s*/, $payment_gateway->gateway_action );
-
- my $transaction = new $namespace( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
- );
-
- $transaction->content(
- 'type' => $options{method},
- $self->_bop_auth(\%options),
- 'action' => $action1,
- 'description' => $options{'description'},
- 'amount' => $options{amount},
- #'invoice_number' => $options{'invnum'},
- 'customer_id' => $self->custnum,
- %$bop_content,
- 'reference' => $cust_pay_pending->paypendingnum, #for now
- 'email' => $email,
- %content, #after
- );
-
- $cust_pay_pending->status('pending');
- my $cpp_pending_err = $cust_pay_pending->replace;
- return $cpp_pending_err if $cpp_pending_err;
-
- #config?
- my $BOP_TESTING = 0;
- my $BOP_TESTING_SUCCESS = 1;
-
- unless ( $BOP_TESTING ) {
- $transaction->test_transaction(1)
- if $conf->exists('business-onlinepayment-test_transaction');
- $transaction->submit();
- } else {
- if ( $BOP_TESTING_SUCCESS ) {
- $transaction->is_success(1);
- $transaction->authorization('fake auth');
- } else {
- $transaction->is_success(0);
- $transaction->error_message('fake failure');
- }
- }
-
- if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
-
- return { reference => $cust_pay_pending->paypendingnum,
- map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
-
- } elsif ( $transaction->is_success() && $action2 ) {
-
- $cust_pay_pending->status('authorized');
- my $cpp_authorized_err = $cust_pay_pending->replace;
- return $cpp_authorized_err if $cpp_authorized_err;
-
- my $auth = $transaction->authorization;
- my $ordernum = $transaction->can('order_number')
- ? $transaction->order_number
- : '';
-
- my $capture =
- new Business::OnlinePayment( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
- );
-
- my %capture = (
- %content,
- type => $options{method},
- action => $action2,
- $self->_bop_auth(\%options),
- order_number => $ordernum,
- amount => $options{amount},
- authorization => $auth,
- description => $options{'description'},
- );
-
- foreach my $field (qw( authorization_source_code returned_ACI
- transaction_identifier validation_code
- transaction_sequence_num local_transaction_date
- local_transaction_time AVS_result_code )) {
- $capture{$field} = $transaction->$field() if $transaction->can($field);
- }
-
- $capture->content( %capture );
-
- $capture->test_transaction(1)
- if $conf->exists('business-onlinepayment-test_transaction');
- $capture->submit();
-
- unless ( $capture->is_success ) {
- my $e = "Authorization successful but capture failed, custnum #".
- $self->custnum. ': '. $capture->result_code.
- ": ". $capture->error_message;
- warn $e;
- return $e;
- }
-
- }
-
- ###
- # remove paycvv after initial transaction
- ###
-
- #false laziness w/misc/process/payment.cgi - check both to make sure working
- # correctly
- if ( defined $self->dbdef_table->column('paycvv')
- && length($self->paycvv)
- && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
- ) {
- my $error = $self->remove_cvv;
- if ( $error ) {
- warn "WARNING: error removing cvv: $error\n";
- }
- }
-
- ###
- # Tokenize
- ###
-
-
- if ( $transaction->can('card_token') && $transaction->card_token ) {
-
- $self->card_token($transaction->card_token);
-
- if ( $options{'payinfo'} eq $self->payinfo ) {
- $self->payinfo($transaction->card_token);
- my $error = $self->replace;
- if ( $error ) {
- warn "WARNING: error storing token: $error, but proceeding anyway\n";
- }
- }
-
- }
-
- ###
- # result handling
- ###
-
- $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
-
-}
-
-=item fake_bop
-
-=cut
-
-sub fake_bop {
- my $self = shift;
-
- my %options = ();
- if (ref($_[0]) eq 'HASH') {
- %options = %{$_[0]};
- } else {
- my ( $method, $amount ) = ( shift, shift );
- %options = @_;
- $options{method} = $method;
- $options{amount} = $amount;
- }
-
- if ( $options{'fake_failure'} ) {
- return "Error: No error; test failure requested with fake_failure";
- }
-
- #my $paybatch = '';
- #if ( $payment_gateway->gatewaynum ) { # agent override
- # $paybatch = $payment_gateway->gatewaynum. '-';
- #}
- #
- #$paybatch .= "$processor:". $transaction->authorization;
- #
- #$paybatch .= ':'. $transaction->order_number
- # if $transaction->can('order_number')
- # && length($transaction->order_number);
-
- my $paybatch = 'FakeProcessor:54:32';
-
- my $cust_pay = new FS::cust_pay ( {
- 'custnum' => $self->custnum,
- 'invnum' => $options{'invnum'},
- 'paid' => $options{amount},
- '_date' => '',
- 'payby' => $bop_method2payby{$options{method}},
- #'payinfo' => $payinfo,
- 'payinfo' => '4111111111111111',
- 'paybatch' => $paybatch,
- #'paydate' => $paydate,
- 'paydate' => '2012-05-01',
- } );
- $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
-
- my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
-
- if ( $error ) {
- $cust_pay->invnum(''); #try again with no specific invnum
- my $error2 = $cust_pay->insert( $options{'manual'} ?
- ( 'manual' => 1 ) : ()
- );
- if ( $error2 ) {
- # gah, even with transactions.
- my $e = 'WARNING: Card/ACH debited but database not updated - '.
- "error inserting (fake!) payment: $error2".
- " (previously tried insert with invnum #$options{'invnum'}" .
- ": $error )";
- warn $e;
- return $e;
- }
- }
-
- if ( $options{'paynum_ref'} ) {
- ${ $options{'paynum_ref'} } = $cust_pay->paynum;
- }
-
- return ''; #no error
-
-}
-
-
-# item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
-#
-# Wraps up processing of a realtime credit card, ACH (electronic check) or
-# phone bill transaction.
-
-sub _realtime_bop_result {
- my( $self, $cust_pay_pending, $transaction, %options ) = @_;
- if ( $DEBUG ) {
- warn "$me _realtime_bop_result: pending transaction ".
- $cust_pay_pending->paypendingnum. "\n";
- warn " $_ => $options{$_}\n" foreach keys %options;
- }
-
- my $payment_gateway = $options{payment_gateway}
- or return "no payment gateway in arguments to _realtime_bop_result";
-
- $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
- my $cpp_captured_err = $cust_pay_pending->replace;
- return $cpp_captured_err if $cpp_captured_err;
-
- if ( $transaction->is_success() ) {
-
- my $paybatch = '';
- if ( $payment_gateway->gatewaynum ) { # agent override
- $paybatch = $payment_gateway->gatewaynum. '-';
- }
-
- $paybatch .= $payment_gateway->gateway_module. ":".
- $transaction->authorization;
-
- $paybatch .= ':'. $transaction->order_number
- if $transaction->can('order_number')
- && length($transaction->order_number);
-
- my $cust_pay = new FS::cust_pay ( {
- 'custnum' => $self->custnum,
- 'invnum' => $options{'invnum'},
- 'paid' => $cust_pay_pending->paid,
- '_date' => '',
- 'payby' => $cust_pay_pending->payby,
- 'payinfo' => $options{'payinfo'},
- 'paybatch' => $paybatch,
- 'paydate' => $cust_pay_pending->paydate,
- 'pkgnum' => $cust_pay_pending->pkgnum,
- } );
- #doesn't hurt to know, even though the dup check is in cust_pay_pending now
- $cust_pay->payunique( $options{payunique} )
- if defined($options{payunique}) && length($options{payunique});
-
- my $oldAutoCommit = $FS::UID::AutoCommit;
- local $FS::UID::AutoCommit = 0;
- my $dbh = dbh;
-
- #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
-
- my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
-
- if ( $error ) {
- $cust_pay->invnum(''); #try again with no specific invnum
- my $error2 = $cust_pay->insert( $options{'manual'} ?
- ( 'manual' => 1 ) : ()
- );
- if ( $error2 ) {
- # gah. but at least we have a record of the state we had to abort in
- # from cust_pay_pending now.
- my $e = "WARNING: $options{method} captured but payment not recorded -".
- " error inserting payment (". $payment_gateway->gateway_module.
- "): $error2".
- " (previously tried insert with invnum #$options{'invnum'}" .
- ": $error ) - pending payment saved as paypendingnum ".
- $cust_pay_pending->paypendingnum. "\n";
- warn $e;
- return $e;
- }
- }
-
- my $jobnum = $cust_pay_pending->jobnum;
- if ( $jobnum ) {
- my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
-
- unless ( $placeholder ) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- my $e = "WARNING: $options{method} captured but job $jobnum not ".
- "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n";
- warn $e;
- return $e;
- }
-
- $error = $placeholder->delete;
-
- if ( $error ) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- my $e = "WARNING: $options{method} captured but could not delete ".
- "job $jobnum for paypendingnum ".
- $cust_pay_pending->paypendingnum. ": $error\n";
- warn $e;
- return $e;
- }
-
- }
-
- if ( $options{'paynum_ref'} ) {
- ${ $options{'paynum_ref'} } = $cust_pay->paynum;
- }
-
- $cust_pay_pending->status('done');
- $cust_pay_pending->statustext('captured');
- $cust_pay_pending->paynum($cust_pay->paynum);
- my $cpp_done_err = $cust_pay_pending->replace;
-
- if ( $cpp_done_err ) {
-
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- my $e = "WARNING: $options{method} captured but payment not recorded - ".
- "error updating status for paypendingnum ".
- $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
- warn $e;
- return $e;
-
- } else {
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
- if ( $options{'apply'} ) {
- my $apply_error = $self->apply_payments_and_credits;
- if ( $apply_error ) {
- warn "WARNING: error applying payment: $apply_error\n";
- #but we still should return no error cause the payment otherwise went
- #through...
- }
- }
-
- return ''; #no error
-
- }
-
- } else {
-
- my $perror = $payment_gateway->gateway_module. " error: ".
- $transaction->error_message;
-
- my $jobnum = $cust_pay_pending->jobnum;
- if ( $jobnum ) {
- my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
-
- if ( $placeholder ) {
- my $error = $placeholder->depended_delete;
- $error ||= $placeholder->delete;
- warn "error removing provisioning jobs after declined paypendingnum ".
- $cust_pay_pending->paypendingnum. "\n";
- } else {
- my $e = "error finding job $jobnum for declined paypendingnum ".
- $cust_pay_pending->paypendingnum. "\n";
- warn $e;
- }
-
- }
-
- unless ( $transaction->error_message ) {
-
- my $t_response;
- if ( $transaction->can('response_page') ) {
- $t_response = {
- 'page' => ( $transaction->can('response_page')
- ? $transaction->response_page
- : ''
- ),
- 'code' => ( $transaction->can('response_code')
- ? $transaction->response_code
- : ''
- ),
- 'headers' => ( $transaction->can('response_headers')
- ? $transaction->response_headers
- : ''
- ),
- };
- } else {
- $t_response .=
- "No additional debugging information available for ".
- $payment_gateway->gateway_module;
- }
-
- $perror .= "No error_message returned from ".
- $payment_gateway->gateway_module. " -- ".
- ( ref($t_response) ? Dumper($t_response) : $t_response );
-
- }
-
- if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
- && $conf->exists('emaildecline')
- && grep { $_ ne 'POST' } $self->invoicing_list
- && ! grep { $transaction->error_message =~ /$_/ }
- $conf->config('emaildecline-exclude')
- ) {
-
- # Send a decline alert to the customer.
- my $msgnum = $conf->config('decline_msgnum', $self->agentnum);
- my $error = '';
- if ( $msgnum ) {
- my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
- $error = $msg_template->send( 'cust_main' => $self );
- }
- else { #!$msgnum
-
- my @templ = $conf->config('declinetemplate');
- my $template = new Text::Template (
- TYPE => 'ARRAY',
- SOURCE => [ map "$_\n", @templ ],
- ) or return "($perror) can't create template: $Text::Template::ERROR";
- $template->compile()
- or return "($perror) can't compile template: $Text::Template::ERROR";
-
- my $templ_hash = {
- 'company_name' =>
- scalar( $conf->config('company_name', $self->agentnum ) ),
- 'company_address' =>
- join("\n", $conf->config('company_address', $self->agentnum ) ),
- 'error' => $transaction->error_message,
- };
-
- my $error = send_email(
- 'from' => $conf->config('invoice_from', $self->agentnum ),
- 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
- 'subject' => 'Your payment could not be processed',
- 'body' => [ $template->fill_in(HASH => $templ_hash) ],
- );
- }
-
- $perror .= " (also received error sending decline notification: $error)"
- if $error;
-
- }
-
- $cust_pay_pending->status('done');
- $cust_pay_pending->statustext("declined: $perror");
- my $cpp_done_err = $cust_pay_pending->replace;
- if ( $cpp_done_err ) {
- my $e = "WARNING: $options{method} declined but pending payment not ".
- "resolved - error updating status for paypendingnum ".
- $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
- warn $e;
- $perror = "$e ($perror)";
- }
-
- return $perror;
- }
-
-}
-
-=item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
-
-Verifies successful third party processing of a realtime credit card,
-ACH (electronic check) or phone bill transaction via a
-Business::OnlineThirdPartyPayment realtime gateway. See
-L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
-
-Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
-
-The additional options I<payname>, I<city>, I<state>,
-I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
-if set, will override the value from the customer record.
-
-I<description> is a free-text field passed to the gateway. It defaults to
-"Internet services".
-
-If an I<invnum> is specified, this payment (if successful) is applied to the
-specified invoice. If you don't specify an I<invnum> you might want to
-call the B<apply_payments> method.
-
-I<quiet> can be set true to surpress email decline notices.
-
-I<paynum_ref> can be set to a scalar reference. It will be filled in with the
-resulting paynum, if any.
-
-I<payunique> is a unique identifier for this payment.
-
-Returns a hashref containing elements bill_error (which will be undefined
-upon success) and session_id of any associated session.
-
-=cut
-
-sub realtime_botpp_capture {
- my( $self, $cust_pay_pending, %options ) = @_;
- if ( $DEBUG ) {
- warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n";
- warn " $_ => $options{$_}\n" foreach keys %options;
- }
-
- eval "use Business::OnlineThirdPartyPayment";
- die $@ if $@;
-
- ###
- # select the gateway
- ###
-
- my $method = FS::payby->payby2bop($cust_pay_pending->payby);
-
- my $payment_gateway = $cust_pay_pending->gatewaynum
- ? qsearchs( 'payment_gateway',
- { gatewaynum => $cust_pay_pending->gatewaynum }
- )
- : $self->agent->payment_gateway( 'method' => $method,
- # 'invnum' => $cust_pay_pending->invnum,
- # 'payinfo' => $cust_pay_pending->payinfo,
- );
-
- $options{payment_gateway} = $payment_gateway; # for the helper subs
-
- ###
- # massage data
- ###
-
- my @invoicing_list = $self->invoicing_list_emailonly;
- if ( $conf->exists('emailinvoiceautoalways')
- || $conf->exists('emailinvoiceauto') && ! @invoicing_list
- || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
- push @invoicing_list, $self->all_emails;
- }
-
- my $email = ($conf->exists('business-onlinepayment-email-override'))
- ? $conf->config('business-onlinepayment-email-override')
- : $invoicing_list[0];
-
- my %content = ();
-
- $content{email_customer} =
- ( $conf->exists('business-onlinepayment-email_customer')
- || $conf->exists('business-onlinepayment-email-override') );
-
- ###
- # run transaction(s)
- ###
-
- my $transaction =
- new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
- $self->_bop_options(\%options),
- );
-
- $transaction->reference({ %options });
-
- $transaction->content(
- 'type' => $method,
- $self->_bop_auth(\%options),
- 'action' => 'Post Authorization',
- 'description' => $options{'description'},
- 'amount' => $cust_pay_pending->paid,
- #'invoice_number' => $options{'invnum'},
- 'customer_id' => $self->custnum,
- 'referer' => 'http://cleanwhisker.420.am/',
- 'reference' => $cust_pay_pending->paypendingnum,
- 'email' => $email,
- 'phone' => $self->daytime || $self->night,
- %content, #after
- # plus whatever is required for bogus capture avoidance
- );
-
- $transaction->submit();
-
- my $error =
- $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
-
- {
- bill_error => $error,
- session_id => $cust_pay_pending->session_id,
- }
-
-}
-
-=item default_payment_gateway DEPRECATED -- use agent->payment_gateway
-
-=cut
-
-sub default_payment_gateway {
- my( $self, $method ) = @_;
-
- die "Real-time processing not enabled\n"
- unless $conf->exists('business-onlinepayment');
-
- #warn "default_payment_gateway deprecated -- use agent->payment_gateway\n";
-
- #load up config
- my $bop_config = 'business-onlinepayment';
- $bop_config .= '-ach'
- if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
- my ( $processor, $login, $password, $action, @bop_options ) =
- $conf->config($bop_config);
- $action ||= 'normal authorization';
- pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
- die "No real-time processor is enabled - ".
- "did you set the business-onlinepayment configuration value?\n"
- unless $processor;
-
- ( $processor, $login, $password, $action, @bop_options )
-}
+Documentation on realtime billing methods has been moved to
+L<FS::cust_main::Billing_Realtime>.
=item remove_cvv
@@ -5330,332 +2338,6 @@ sub remove_cvv {
'';
}
-=item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
-
-Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
-via a Business::OnlinePayment realtime gateway. See
-L<http://420.am/business-onlinepayment> for supported gateways.
-
-Available methods are: I<CC>, I<ECHECK> and I<LEC>
-
-Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
-
-Most gateways require a reference to an original payment transaction to refund,
-so you probably need to specify a I<paynum>.
-
-I<amount> defaults to the original amount of the payment if not specified.
-
-I<reason> specifies a reason for the refund.
-
-I<paydate> specifies the expiration date for a credit card overriding the
-value from the customer record or the payment record. Specified as yyyy-mm-dd
-
-Implementation note: If I<amount> is unspecified or equal to the amount of the
-orignal payment, first an attempt is made to "void" the transaction via
-the gateway (to cancel a not-yet settled transaction) and then if that fails,
-the normal attempt is made to "refund" ("credit") the transaction via the
-gateway is attempted.
-
-#The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
-#I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
-#if set, will override the value from the customer record.
-
-#If an I<invnum> is specified, this payment (if successful) is applied to the
-#specified invoice. If you don't specify an I<invnum> you might want to
-#call the B<apply_payments> method.
-
-=cut
-
-#some false laziness w/realtime_bop, not enough to make it worth merging
-#but some useful small subs should be pulled out
-sub realtime_refund_bop {
- my $self = shift;
-
- my %options = ();
- if (ref($_[0]) eq 'HASH') {
- %options = %{$_[0]};
- } else {
- my $method = shift;
- %options = @_;
- $options{method} = $method;
- }
-
- if ( $DEBUG ) {
- warn "$me realtime_refund_bop (new): $options{method} refund\n";
- warn " $_ => $options{$_}\n" foreach keys %options;
- }
-
- ###
- # look up the original payment and optionally a gateway for that payment
- ###
-
- my $cust_pay = '';
- my $amount = $options{'amount'};
-
- my( $processor, $login, $password, @bop_options, $namespace ) ;
- my( $auth, $order_number ) = ( '', '', '' );
-
- if ( $options{'paynum'} ) {
-
- warn " paynum: $options{paynum}\n" if $DEBUG > 1;
- $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
- or return "Unknown paynum $options{'paynum'}";
- $amount ||= $cust_pay->paid;
-
- $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
- or return "Can't parse paybatch for paynum $options{'paynum'}: ".
- $cust_pay->paybatch;
- my $gatewaynum = '';
- ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
-
- if ( $gatewaynum ) { #gateway for the payment to be refunded
-
- my $payment_gateway =
- qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
- die "payment gateway $gatewaynum not found"
- unless $payment_gateway;
-
- $processor = $payment_gateway->gateway_module;
- $login = $payment_gateway->gateway_username;
- $password = $payment_gateway->gateway_password;
- $namespace = $payment_gateway->gateway_namespace;
- @bop_options = $payment_gateway->options;
-
- } else { #try the default gateway
-
- my $conf_processor;
- my $payment_gateway =
- $self->agent->payment_gateway('method' => $options{method});
-
- ( $conf_processor, $login, $password, $namespace ) =
- map { my $method = "gateway_$_"; $payment_gateway->$method }
- qw( module username password namespace );
-
- @bop_options = $payment_gateway->gatewaynum
- ? $payment_gateway->options
- : @{ $payment_gateway->get('options') };
-
- return "processor of payment $options{'paynum'} $processor does not".
- " match default processor $conf_processor"
- unless $processor eq $conf_processor;
-
- }
-
-
- } else { # didn't specify a paynum, so look for agent gateway overrides
- # like a normal transaction
-
- my $payment_gateway =
- $self->agent->payment_gateway( 'method' => $options{method},
- #'payinfo' => $payinfo,
- );
- my( $processor, $login, $password, $namespace ) =
- map { my $method = "gateway_$_"; $payment_gateway->$method }
- qw( module username password namespace );
-
- my @bop_options = $payment_gateway->gatewaynum
- ? $payment_gateway->options
- : @{ $payment_gateway->get('options') };
-
- }
- return "neither amount nor paynum specified" unless $amount;
-
- eval "use $namespace";
- die $@ if $@;
-
- my %content = (
- 'type' => $options{method},
- 'login' => $login,
- 'password' => $password,
- 'order_number' => $order_number,
- 'amount' => $amount,
- 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
- );
- $content{authorization} = $auth
- if length($auth); #echeck/ACH transactions have an order # but no auth
- #(at least with authorize.net)
-
- my $disable_void_after;
- if ($conf->exists('disable_void_after')
- && $conf->config('disable_void_after') =~ /^(\d+)$/) {
- $disable_void_after = $1;
- }
-
- #first try void if applicable
- if ( $cust_pay && $cust_pay->paid == $amount
- && (
- ( not defined($disable_void_after) )
- || ( time < ($cust_pay->_date + $disable_void_after ) )
- )
- ) {
- warn " attempting void\n" if $DEBUG > 1;
- my $void = new Business::OnlinePayment( $processor, @bop_options );
- if ( $void->can('info') ) {
- if ( $cust_pay->payby eq 'CARD'
- && $void->info('CC_void_requires_card') )
- {
- $content{'card_number'} = $cust_pay->payinfo;
- } elsif ( $cust_pay->payby eq 'CHEK'
- && $void->info('ECHECK_void_requires_account') )
- {
- ( $content{'account_number'}, $content{'routing_code'} ) =
- split('@', $cust_pay->payinfo);
- $content{'name'} = $self->get('first'). ' '. $self->get('last');
- }
- }
- $void->content( 'action' => 'void', %content );
- $void->test_transaction(1)
- if $conf->exists('business-onlinepayment-test_transaction');
- $void->submit();
- if ( $void->is_success ) {
- my $error = $cust_pay->void($options{'reason'});
- if ( $error ) {
- # gah, even with transactions.
- my $e = 'WARNING: Card/ACH voided but database not updated - '.
- "error voiding payment: $error";
- warn $e;
- return $e;
- }
- warn " void successful\n" if $DEBUG > 1;
- return '';
- }
- }
-
- warn " void unsuccessful, trying refund\n"
- if $DEBUG > 1;
-
- #massage data
- my $address = $self->address1;
- $address .= ", ". $self->address2 if $self->address2;
-
- my($payname, $payfirst, $paylast);
- if ( $self->payname && $options{method} ne 'ECHECK' ) {
- $payname = $self->payname;
- $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
- or return "Illegal payname $payname";
- ($payfirst, $paylast) = ($1, $2);
- } else {
- $payfirst = $self->getfield('first');
- $paylast = $self->getfield('last');
- $payname = "$payfirst $paylast";
- }
-
- my @invoicing_list = $self->invoicing_list_emailonly;
- if ( $conf->exists('emailinvoiceautoalways')
- || $conf->exists('emailinvoiceauto') && ! @invoicing_list
- || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
- push @invoicing_list, $self->all_emails;
- }
-
- my $email = ($conf->exists('business-onlinepayment-email-override'))
- ? $conf->config('business-onlinepayment-email-override')
- : $invoicing_list[0];
-
- my $payip = exists($options{'payip'})
- ? $options{'payip'}
- : $self->payip;
- $content{customer_ip} = $payip
- if length($payip);
-
- my $payinfo = '';
- if ( $options{method} eq 'CC' ) {
-
- if ( $cust_pay ) {
- $content{card_number} = $payinfo = $cust_pay->payinfo;
- (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
- =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
- ($content{expiration} = "$2/$1"); # where available
- } else {
- $content{card_number} = $payinfo = $self->payinfo;
- (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
- =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
- $content{expiration} = "$2/$1";
- }
-
- } elsif ( $options{method} eq 'ECHECK' ) {
-
- if ( $cust_pay ) {
- $payinfo = $cust_pay->payinfo;
- } else {
- $payinfo = $self->payinfo;
- }
- ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
- $content{bank_name} = $self->payname;
- $content{account_type} = 'CHECKING';
- $content{account_name} = $payname;
- $content{customer_org} = $self->company ? 'B' : 'I';
- $content{customer_ssn} = $self->ss;
- } elsif ( $options{method} eq 'LEC' ) {
- $content{phone} = $payinfo = $self->payinfo;
- }
-
- #then try refund
- my $refund = new Business::OnlinePayment( $processor, @bop_options );
- my %sub_content = $refund->content(
- 'action' => 'credit',
- 'customer_id' => $self->custnum,
- 'last_name' => $paylast,
- 'first_name' => $payfirst,
- 'name' => $payname,
- 'address' => $address,
- 'city' => $self->city,
- 'state' => $self->state,
- 'zip' => $self->zip,
- 'country' => $self->country,
- 'email' => $email,
- 'phone' => $self->daytime || $self->night,
- %content, #after
- );
- warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
- if $DEBUG > 1;
- $refund->test_transaction(1)
- if $conf->exists('business-onlinepayment-test_transaction');
- $refund->submit();
-
- return "$processor error: ". $refund->error_message
- unless $refund->is_success();
-
- my $paybatch = "$processor:". $refund->authorization;
- $paybatch .= ':'. $refund->order_number
- if $refund->can('order_number') && $refund->order_number;
-
- while ( $cust_pay && $cust_pay->unapplied < $amount ) {
- my @cust_bill_pay = $cust_pay->cust_bill_pay;
- last unless @cust_bill_pay;
- my $cust_bill_pay = pop @cust_bill_pay;
- my $error = $cust_bill_pay->delete;
- last if $error;
- }
-
- my $cust_refund = new FS::cust_refund ( {
- 'custnum' => $self->custnum,
- 'paynum' => $options{'paynum'},
- 'refund' => $amount,
- '_date' => '',
- 'payby' => $bop_method2payby{$options{method}},
- 'payinfo' => $payinfo,
- 'paybatch' => $paybatch,
- 'reason' => $options{'reason'} || 'card or ACH refund',
- } );
- my $error = $cust_refund->insert;
- if ( $error ) {
- $cust_refund->paynum(''); #try again with no specific paynum
- my $error2 = $cust_refund->insert;
- if ( $error2 ) {
- # gah, even with transactions.
- my $e = 'WARNING: Card/ACH refunded but database not updated - '.
- "error inserting refund ($processor): $error2".
- " (previously tried insert with paynum #$options{'paynum'}" .
- ": $error )";
- warn $e;
- return $e;
- }
- }
-
- ''; #no error
-
-}
-
=item batch_card OPTION => VALUE...
Adds a payment for this invoice to the pending credit card batch (see
@@ -5676,7 +2358,7 @@ sub batch_card {
return '' unless $amount > 0;
my $invnum = delete $options{invnum};
- my $payby = $options{invnum} || $self->payby; #dubious
+ my $payby = $options{payby} || $self->payby; #still dubious
if ($options{'realtime'}) {
return $self->realtime_bop( FS::payby->payby2bop($self->payby),
@@ -5786,244 +2468,6 @@ sub batch_card {
'';
}
-=item apply_payments_and_credits [ OPTION => VALUE ... ]
-
-Applies unapplied payments and credits.
-
-In most cases, this new method should be used in place of sequential
-apply_payments and apply_credits methods.
-
-A hash of optional arguments may be passed. Currently "manual" is supported.
-If true, a payment receipt is sent instead of a statement when
-'payment_receipt_email' configuration option is set.
-
-If there is an error, returns the error, otherwise returns false.
-
-=cut
-
-sub apply_payments_and_credits {
- my( $self, %options ) = @_;
-
- 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;
-
- $self->select_for_update; #mutex
-
- foreach my $cust_bill ( $self->open_cust_bill ) {
- my $error = $cust_bill->apply_payments_and_credits(%options);
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return "Error applying: $error";
- }
- }
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- ''; #no error
-
-}
-
-=item apply_credits OPTION => VALUE ...
-
-Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
-to outstanding invoice balances in chronological order (or reverse
-chronological order if the I<order> option is set to B<newest>) and returns the
-value of any remaining unapplied credits available for refund (see
-L<FS::cust_refund>).
-
-Dies if there is an error.
-
-=cut
-
-sub apply_credits {
- my $self = shift;
- my %opt = @_;
-
- 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;
-
- $self->select_for_update; #mutex
-
- unless ( $self->total_unapplied_credits ) {
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
- return 0;
- }
-
- my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
- qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
-
- my @invoices = $self->open_cust_bill;
- @invoices = sort { $b->_date <=> $a->_date } @invoices
- if defined($opt{'order'}) && $opt{'order'} eq 'newest';
-
- if ( $conf->exists('pkg-balances') ) {
- # limit @credits to those w/ a pkgnum grepped from $self
- my %pkgnums = ();
- foreach my $i (@invoices) {
- foreach my $li ( $i->cust_bill_pkg ) {
- $pkgnums{$li->pkgnum} = 1;
- }
- }
- @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
- }
-
- my $credit;
-
- foreach my $cust_bill ( @invoices ) {
-
- if ( !defined($credit) || $credit->credited == 0) {
- $credit = pop @credits or last;
- }
-
- my $owed;
- if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
- $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
- } else {
- $owed = $cust_bill->owed;
- }
- unless ( $owed > 0 ) {
- push @credits, $credit;
- next;
- }
-
- my $amount = min( $credit->credited, $owed );
-
- my $cust_credit_bill = new FS::cust_credit_bill ( {
- 'crednum' => $credit->crednum,
- 'invnum' => $cust_bill->invnum,
- 'amount' => $amount,
- } );
- $cust_credit_bill->pkgnum( $credit->pkgnum )
- if $conf->exists('pkg-balances') && $credit->pkgnum;
- my $error = $cust_credit_bill->insert;
- if ( $error ) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- die $error;
- }
-
- redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
-
- }
-
- my $total_unapplied_credits = $self->total_unapplied_credits;
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
- return $total_unapplied_credits;
-}
-
-=item apply_payments [ OPTION => VALUE ... ]
-
-Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
-to outstanding invoice balances in chronological order.
-
- #and returns the value of any remaining unapplied payments.
-
-A hash of optional arguments may be passed. Currently "manual" is supported.
-If true, a payment receipt is sent instead of a statement when
-'payment_receipt_email' configuration option is set.
-
-Dies if there is an error.
-
-=cut
-
-sub apply_payments {
- my( $self, %options ) = @_;
-
- 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;
-
- $self->select_for_update; #mutex
-
- #return 0 unless
-
- my @payments = sort { $b->_date <=> $a->_date }
- grep { $_->unapplied > 0 }
- $self->cust_pay;
-
- my @invoices = sort { $a->_date <=> $b->_date}
- grep { $_->owed > 0 }
- $self->cust_bill;
-
- if ( $conf->exists('pkg-balances') ) {
- # limit @payments to those w/ a pkgnum grepped from $self
- my %pkgnums = ();
- foreach my $i (@invoices) {
- foreach my $li ( $i->cust_bill_pkg ) {
- $pkgnums{$li->pkgnum} = 1;
- }
- }
- @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
- }
-
- my $payment;
-
- foreach my $cust_bill ( @invoices ) {
-
- if ( !defined($payment) || $payment->unapplied == 0 ) {
- $payment = pop @payments or last;
- }
-
- my $owed;
- if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
- $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
- } else {
- $owed = $cust_bill->owed;
- }
- unless ( $owed > 0 ) {
- push @payments, $payment;
- next;
- }
-
- my $amount = min( $payment->unapplied, $owed );
-
- my $cust_bill_pay = new FS::cust_bill_pay ( {
- 'paynum' => $payment->paynum,
- 'invnum' => $cust_bill->invnum,
- 'amount' => $amount,
- } );
- $cust_bill_pay->pkgnum( $payment->pkgnum )
- if $conf->exists('pkg-balances') && $payment->pkgnum;
- my $error = $cust_bill_pay->insert(%options);
- if ( $error ) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- die $error;
- }
-
- redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
-
- }
-
- my $total_unapplied_payments = $self->total_unapplied_payments;
-
- $dbh->commit or die $dbh->errstr if $oldAutoCommit;
-
- return $total_unapplied_payments;
-}
-
=item total_owed
Returns the total owed for this customer on all invoices
@@ -6058,7 +2502,7 @@ sub total_owed_date {
AND _date <= $time
";
- sprintf( "%.2f", $self->scalar_sql($sql) );
+ sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
}
@@ -6138,7 +2582,7 @@ sub total_unapplied_credits {
WHERE custnum = $custnum
";
- sprintf( "%.2f", $self->scalar_sql($sql) );
+ sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
}
@@ -6176,7 +2620,7 @@ sub total_unapplied_payments {
WHERE custnum = $custnum
";
- sprintf( "%.2f", $self->scalar_sql($sql) );
+ sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
}
@@ -6214,7 +2658,7 @@ sub total_unapplied_refunds {
WHERE custnum = $custnum
";
- sprintf( "%.2f", $self->scalar_sql($sql) );
+ sprintf( "%.2f", $self->scalar_sql($sql) || 0 );
}
@@ -6272,7 +2716,7 @@ sub balance_date_range {
my $self = shift;
my $sql = 'SELECT SUM('. $self->balance_date_sql(@_).
') FROM cust_main WHERE custnum='. $self->custnum;
- sprintf( "%.2f", $self->scalar_sql($sql) );
+ sprintf( '%.2f', $self->scalar_sql($sql) || 0 );
}
=item balance_pkgnum PKGNUM
@@ -6933,17 +3377,29 @@ sub charge_postal_fee {
$error ? $error : $cust_pkg;
}
-=item cust_bill
+=item cust_bill [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
Returns all the invoices (see L<FS::cust_bill>) for this customer.
+Optionally, a list or hashref of additional arguments to the qsearch call can
+be passed.
+
=cut
sub cust_bill {
my $self = shift;
- map { $_ } #return $self->num_cust_bill unless wantarray;
- sort { $a->_date <=> $b->_date }
- qsearch('cust_bill', { 'custnum' => $self->custnum, } )
+ my $opt = ref($_[0]) ? shift : { @_ };
+
+ #return $self->num_cust_bill unless wantarray || keys %$opt;
+
+ $opt->{'table'} = 'cust_bill';
+ $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
+ $opt->{'hashref'}{'custnum'} = $self->custnum;
+ $opt->{'order_by'} ||= 'ORDER BY _date ASC';
+
+ map { $_ } #behavior of sort undefined in scalar context
+ sort { $a->_date <=> $b->_date }
+ qsearch($opt);
}
=item open_cust_bill
@@ -6956,26 +3412,36 @@ customer.
sub open_cust_bill {
my $self = shift;
- qsearch({
- 'table' => 'cust_bill',
- 'hashref' => { 'custnum' => $self->custnum, },
+ $self->cust_bill(
'extra_sql' => ' AND '. FS::cust_bill->owed_sql. ' > 0',
- 'order_by' => 'ORDER BY _date ASC',
- });
+ #@_
+ );
}
-=item cust_statements
+=item cust_statement [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
Returns all the statements (see L<FS::cust_statement>) for this customer.
+Optionally, a list or hashref of additional arguments to the qsearch call can
+be passed.
+
=cut
sub cust_statement {
my $self = shift;
- map { $_ } #return $self->num_cust_statement unless wantarray;
- sort { $a->_date <=> $b->_date }
- qsearch('cust_statement', { 'custnum' => $self->custnum, } )
+ my $opt = ref($_[0]) ? shift : { @_ };
+
+ #return $self->num_cust_statement unless wantarray || keys %$opt;
+
+ $opt->{'table'} = 'cust_statement';
+ $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
+ $opt->{'hashref'}{'custnum'} = $self->custnum;
+ $opt->{'order_by'} ||= 'ORDER BY _date ASC';
+
+ map { $_ } #behavior of sort undefined in scalar context
+ sort { $a->_date <=> $b->_date }
+ qsearch($opt);
}
=item cust_credit
@@ -7066,17 +3532,29 @@ sub cust_pay_void {
qsearch( 'cust_pay_void', { 'custnum' => $self->custnum } )
}
-=item cust_pay_batch
+=item cust_pay_batch [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
Returns all batched payments (see L<FS::cust_pay_void>) for this customer.
+Optionally, a list or hashref of additional arguments to the qsearch call can
+be passed.
+
=cut
sub cust_pay_batch {
my $self = shift;
- map { $_ } #return $self->num_cust_pay_batch unless wantarray;
- sort { $a->paybatchnum <=> $b->paybatchnum }
- qsearch( 'cust_pay_batch', { 'custnum' => $self->custnum } )
+ my $opt = ref($_[0]) ? shift : { @_ };
+
+ #return $self->num_cust_statement unless wantarray || keys %$opt;
+
+ $opt->{'table'} = 'cust_pay_batch';
+ $opt->{'hashref'} ||= {}; #i guess it would autovivify anyway...
+ $opt->{'hashref'}{'custnum'} = $self->custnum;
+ $opt->{'order_by'} ||= 'ORDER BY paybatchnum ASC';
+
+ map { $_ } #behavior of sort undefined in scalar context
+ sort { $a->paybatchnum <=> $b->paybatchnum }
+ qsearch($opt);
}
=item cust_pay_pending
@@ -7097,6 +3575,26 @@ sub cust_pay_pending {
);
}
+=item cust_pay_pending_attempt
+
+Returns all payment attempts / declined payments for this customer, as pending
+payments objects (see L<FS::cust_pay_pending>), with status "done" but without
+a corresponding payment (see L<FS::cust_pay>).
+
+=cut
+
+sub cust_pay_pending_attempt {
+ my $self = shift;
+ return $self->num_cust_pay_pending_attempt unless wantarray;
+ sort { $a->_date <=> $b->_date }
+ qsearch( 'cust_pay_pending', {
+ 'custnum' => $self->custnum,
+ 'status' => 'done',
+ 'paynum' => '',
+ },
+ );
+}
+
=item num_cust_pay_pending
Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
@@ -7107,11 +3605,28 @@ cust_pay_pending method is used in a scalar context.
sub num_cust_pay_pending {
my $self = shift;
- my $sql = " SELECT COUNT(*) FROM cust_pay_pending ".
- " WHERE custnum = ? AND status != 'done' ";
- my $sth = dbh->prepare($sql) or die dbh->errstr;
- $sth->execute($self->custnum) or die $sth->errstr;
- $sth->fetchrow_arrayref->[0];
+ $self->scalar_sql(
+ " SELECT COUNT(*) FROM cust_pay_pending ".
+ " WHERE custnum = ? AND status != 'done' ",
+ $self->custnum
+ );
+}
+
+=item num_cust_pay_pending_attempt
+
+Returns the number of pending payments (see L<FS::cust_pay_pending>) for this
+customer, with status "done" but without a corresp. Also called automatically when the
+cust_pay_pending method is used in a scalar context.
+
+=cut
+
+sub num_cust_pay_pending_attempt {
+ my $self = shift;
+ $self->scalar_sql(
+ " SELECT COUNT(*) FROM cust_pay_pending ".
+ " WHERE custnum = ? AND status = 'done' AND paynum IS NULL",
+ $self->custnum
+ );
}
=item cust_refund
@@ -7270,38 +3785,6 @@ Currently this only makes sense for "CCH" as DATA_VENDOR.
=cut
-sub geocode {
- my ($self, $data_vendor) = (shift, shift); #always cch for now
-
- my $geocode = $self->get('geocode'); #XXX only one data_vendor for geocode
- return $geocode if $geocode;
-
- my $prefix = ( $conf->exists('tax-ship_address') && length($self->ship_last) )
- ? 'ship_'
- : '';
-
- my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
- if $self->country eq 'US';
-
- $zip ||= '';
- $plus4 ||= '';
- #CCH specific location stuff
- my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
-
- my @cust_tax_location =
- qsearch( {
- 'table' => 'cust_tax_location',
- 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor },
- 'extra_sql' => $extra_sql,
- 'order_by' => 'ORDER BY plus4hi',#overlapping with distinct ends
- }
- );
- $geocode = $cust_tax_location[0]->geocode
- if scalar(@cust_tax_location);
-
- $geocode;
-}
-
=item cust_status
=item status
@@ -7330,7 +3813,6 @@ sub status { shift->cust_status(@_); }
sub cust_status {
my $self = shift;
- # prospect ordered active inactive suspended cancelled
for my $status ( FS::cust_main->statuses() ) {
my $method = $status.'_sql';
my $numnum = ( my $sql = $self->$method() ) =~ s/cust_main\.custnum/?/g;
@@ -7367,9 +3849,9 @@ tie %statuscolor, 'Tie::IxHash',
'prospect' => '7e0079', #'000000', #black? naw, purple
'active' => '00CC00', #green
'ordered' => '009999', #teal? cyan?
- 'inactive' => '0000CC', #blue
'suspended' => 'FF9900', #yellow
'cancelled' => 'FF0000', #red
+ 'inactive' => '0000CC', #blue
;
sub statuscolor { shift->cust_statuscolor(@_); }
@@ -7440,6 +3922,22 @@ sub service_coordinates {
scalar(@svc_X) ? ( $svc_X[0]->latitude, $svc_X[0]->longitude ) : ()
}
+=item masked FIELD
+
+Returns a masked version of the named field
+
+=cut
+
+sub masked {
+my ($self,$field) = @_;
+
+# Show last four
+
+'x'x(length($self->getfield($field))-4).
+ substr($self->getfield($field), (length($self->getfield($field))-4));
+
+}
+
=back
=head1 CLASS METHODS
@@ -7460,6 +3958,24 @@ sub statuses {
keys %statuscolor;
}
+=item cust_status_sql
+
+Returns an SQL fragment to determine the status of a cust_main record, as a
+string.
+
+=cut
+
+sub cust_status_sql {
+ my $sql = 'CASE';
+ for my $status ( FS::cust_main->statuses() ) {
+ my $method = $status.'_sql';
+ $sql .= ' WHEN ('.FS::cust_main->$method.") THEN '$status'";
+ }
+ $sql .= ' END';
+ return $sql;
+}
+
+
=item prospect_sql
Returns an SQL expression identifying prospective cust_main records (customers
@@ -7488,7 +4004,8 @@ recurring packages not yet setup).
=cut
sub ordered_sql {
- " 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) ";
+ FS::cust_main->none_active_sql.
+ " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->ordered_sql. " ) ";
}
=item active_sql
@@ -7502,6 +4019,18 @@ sub active_sql {
" 0 < ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
}
+=item none_active_sql
+
+Returns an SQL expression identifying cust_main records with no active
+recurring packages. This includes customers of status prospect, ordered,
+inactive, and suspended.
+
+=cut
+
+sub none_active_sql {
+ " 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " ) ";
+}
+
=item inactive_sql
Returns an SQL expression identifying inactive cust_main records (customers with
@@ -7509,11 +4038,10 @@ no active recurring packages, but otherwise unsuspended/uncancelled).
=cut
-sub inactive_sql { "
- 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
- AND
- 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
-"; }
+sub inactive_sql {
+ FS::cust_main->none_active_sql.
+ " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " ) ";
+}
=item susp_sql
=item suspended_sql
@@ -7524,11 +4052,10 @@ Returns an SQL expression identifying suspended cust_main records.
sub suspended_sql { susp_sql(@_); }
-sub susp_sql { "
- 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " )
- AND
- 0 = ( $select_count_pkgs AND ". FS::cust_pkg->active_sql. " )
-"; }
+sub susp_sql {
+ FS::cust_main->none_active_sql.
+ " AND 0 < ( $select_count_pkgs AND ". FS::cust_pkg->suspended_sql. " ) ";
+}
=item cancel_sql
=item cancelled_sql
@@ -7549,8 +4076,8 @@ sub cancel_sql {
AND 0 = ( $select_count_pkgs AND $recurring_sql
AND ( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )
)
- AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
";
+# AND 0 = ( $select_count_pkgs AND ". FS::cust_pkg->inactive_sql. " )
}
@@ -7717,510 +4244,11 @@ sub _money_table_where {
}
-=item search HASHREF
-
-(Class method)
-
-Returns a qsearch hash expression to search for parameters specified in
-HASHREF. Valid parameters are
-
-=over 4
-
-=item agentnum
-
-=item status
-
-=item cancelled_pkgs
-
-bool
-
-=item signupdate
-
-listref of start date, end date
-
-=item payby
-
-listref
-
-=item paydate_year
-
-=item paydate_month
-
-=item current_balance
-
-listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
-
-=item cust_fields
-
-=item flattened_pkgs
-
-bool
-
-=back
-
-=cut
-
+#for dyanmic FS::$table->search in httemplate/misc/email_customers.html
+use FS::cust_main::Search;
sub search {
- my ($class, $params) = @_;
-
- my $dbh = dbh;
-
- my @where = ();
- my $orderby;
-
- ##
- # parse agent
- ##
-
- if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
- push @where,
- "cust_main.agentnum = $1";
- }
-
- ##
- # do the same for user
- ##
-
- if ( $params->{'usernum'} =~ /^(\d+)$/ and $1 ) {
- push @where,
- "cust_main.usernum = $1";
- }
-
- ##
- # parse status
- ##
-
- #prospect ordered active inactive suspended cancelled
- if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
- my $method = $params->{'status'}. '_sql';
- #push @where, $class->$method();
- push @where, FS::cust_main->$method();
- }
-
- ##
- # parse cancelled package checkbox
- ##
-
- my $pkgwhere = "";
-
- $pkgwhere .= "AND (cancel = 0 or cancel is null)"
- unless $params->{'cancelled_pkgs'};
-
- ##
- # parse without census tract checkbox
- ##
-
- push @where, "(censustract = '' or censustract is null)"
- if $params->{'no_censustract'};
-
- ##
- # dates
- ##
-
- foreach my $field (qw( signupdate )) {
-
- next unless exists($params->{$field});
-
- my($beginning, $ending, $hour) = @{$params->{$field}};
-
- push @where,
- "cust_main.$field IS NOT NULL",
- "cust_main.$field >= $beginning",
- "cust_main.$field <= $ending";
-
- # XXX: do this for mysql and/or pull it out of here
- if(defined $hour) {
- if ($dbh->{Driver}->{Name} eq 'Pg') {
- push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
- }
- else {
- warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
- }
- }
-
- $orderby ||= "ORDER BY cust_main.$field";
-
- }
-
- ###
- # classnum
- ###
-
- if ( $params->{'classnum'} ) {
-
- my @classnum = ref( $params->{'classnum'} )
- ? @{ $params->{'classnum'} }
- : ( $params->{'classnum'} );
-
- @classnum = grep /^(\d*)$/, @classnum;
-
- if ( @classnum ) {
- push @where, '( '. join(' OR ', map {
- $_ ? "cust_main.classnum = $_"
- : "cust_main.classnum IS NULL"
- }
- @classnum
- ).
- ' )';
- }
-
- }
-
- ###
- # payby
- ###
-
- if ( $params->{'payby'} ) {
-
- my @payby = ref( $params->{'payby'} )
- ? @{ $params->{'payby'} }
- : ( $params->{'payby'} );
-
- @payby = grep /^([A-Z]{4})$/, @{ $params->{'payby'} };
-
- push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
- if @payby;
-
- }
-
- ###
- # paydate_year / paydate_month
- ###
-
- if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
- my $year = $1;
- $params->{'paydate_month'} =~ /^(\d\d?)$/
- or die "paydate_year without paydate_month?";
- my $month = $1;
-
- push @where,
- 'paydate IS NOT NULL',
- "paydate != ''",
- "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
-;
- }
-
- ###
- # invoice terms
- ###
-
- if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
- my $terms = $1;
- if ( $1 eq 'NULL' ) {
- push @where,
- "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
- } else {
- push @where,
- "cust_main.invoice_terms IS NOT NULL",
- "cust_main.invoice_terms = '$1'";
- }
- }
-
- ##
- # amounts
- ##
-
- if ( $params->{'current_balance'} ) {
-
- #my $balance_sql = $class->balance_sql();
- my $balance_sql = FS::cust_main->balance_sql();
-
- my @current_balance =
- ref( $params->{'current_balance'} )
- ? @{ $params->{'current_balance'} }
- : ( $params->{'current_balance'} );
-
- push @where, map { s/current_balance/$balance_sql/; $_ }
- @current_balance;
-
- }
-
- ##
- # custbatch
- ##
-
- if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
- push @where,
- "cust_main.custbatch = '$1'";
- }
-
- ##
- # setup queries, subs, etc. for the search
- ##
-
- $orderby ||= 'ORDER BY custnum';
-
- # here is the agent virtualization
- push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
-
- my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
-
- my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) ';
-
- my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
-
- my $select = join(', ',
- 'cust_main.custnum',
- FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
- );
-
- my(@extra_headers) = ();
- my(@extra_fields) = ();
-
- if ($params->{'flattened_pkgs'}) {
-
- if ($dbh->{Driver}->{Name} eq 'Pg') {
-
- $select .= ", array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
-
- }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
- $select .= ", GROUP_CONCAT(pkg SEPARATOR '|') as magic";
- $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
- }else{
- warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
- "omitting packing information from report.";
- }
-
- my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
-
- my $sth = dbh->prepare($header_query) or die dbh->errstr;
- $sth->execute() or die $sth->errstr;
- my $headerrow = $sth->fetchrow_arrayref;
- my $headercount = $headerrow ? $headerrow->[0] : 0;
- while($headercount) {
- unshift @extra_headers, "Package ". $headercount;
- unshift @extra_fields, eval q!sub {my $c = shift;
- my @a = split '\|', $c->magic;
- my $p = $a[!.--$headercount. q!];
- $p;
- };!;
- }
-
- }
-
- my $sql_query = {
- 'table' => 'cust_main',
- 'select' => $select,
- 'hashref' => {},
- 'extra_sql' => $extra_sql,
- 'order_by' => $orderby,
- 'count_query' => $count_query,
- 'extra_headers' => \@extra_headers,
- 'extra_fields' => \@extra_fields,
- };
-
-}
-
-=item email_search_result HASHREF
-
-(Class method)
-
-Emails a notice to the specified customers.
-
-Valid parameters are those of the L<search> method, plus the following:
-
-=over 4
-
-=item from
-
-From: address
-
-=item subject
-
-Email Subject:
-
-=item html_body
-
-HTML body
-
-=item text_body
-
-Text body
-
-=item job
-
-Optional job queue job for status updates.
-
-=back
-
-Returns an error message, or false for success.
-
-If an error occurs during any email, stops the enture send and returns that
-error. Presumably if you're getting SMTP errors aborting is better than
-retrying everything.
-
-=cut
-
-sub email_search_result {
- my($class, $params) = @_;
-
- my $from = delete $params->{from};
- my $subject = delete $params->{subject};
- my $html_body = delete $params->{html_body};
- my $text_body = delete $params->{text_body};
- my $error = '';
-
- my $job = delete $params->{'job'}
- or die "email_search_result must run from the job queue.\n";
-
- $params->{'payby'} = [ split(/\0/, $params->{'payby'}) ]
- unless ref($params->{'payby'});
-
- my $sql_query = $class->search($params);
-
- my $count_query = delete($sql_query->{'count_query'});
- my $count_sth = dbh->prepare($count_query)
- or die "Error preparing $count_query: ". dbh->errstr;
- $count_sth->execute
- or die "Error executing $count_query: ". $count_sth->errstr;
- my $count_arrayref = $count_sth->fetchrow_arrayref;
- my $num_cust = $count_arrayref->[0];
-
- #my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
- #my @extra_fields = @{ delete($sql_query->{'extra_fields'}) };
-
-
- my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
- my @retry_jobs = ();
- my $success = 0;
-
- #eventually order+limit magic to reduce memory use?
- foreach my $cust_main ( qsearch($sql_query) ) {
-
- #progressbar first, so that the count is right
- $num++;
- if ( time - $min_sec > $last ) {
- my $error = $job->update_statustext(
- int( 100 * $num / $num_cust )
- );
- die $error if $error;
- $last = time;
- }
-
- my $to = $cust_main->invoicing_list_emailonly_scalar;
-
- if( $to ) {
- my @message = (
- 'from' => $from,
- 'to' => $to,
- 'subject' => $subject,
- 'html_body' => $html_body,
- 'text_body' => $text_body,
- );
-
- $error = send_email( generate_email( @message ) );
-
- if($error) {
- # queue the sending of this message so that the user can see what we
- # tried to do, and retry if desired
- my $queue = new FS::queue {
- 'job' => 'FS::Misc::process_send_email',
- 'custnum' => $cust_main->custnum,
- 'status' => 'failed',
- 'statustext' => $error,
- };
- $queue->insert(@message);
- push @retry_jobs, $queue;
- }
- else {
- $success++;
- }
- }
-
- if($success == 0 and
- (scalar(@retry_jobs) > 10 or $num == $num_cust)
- ) {
- # 10 is arbitrary, but if we have enough failures, that's
- # probably a configuration or network problem, and we
- # abort the batch and run away screaming.
- # We NEVER do this if anything was successfully sent.
- $_->delete foreach (@retry_jobs);
- return "multiple failures: '$error'\n";
- }
- }
-
- if(@retry_jobs) {
- # fail the job, but with a status message that makes it clear
- # something was sent.
- return "Sent $success, failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
- }
-
- return '';
-}
-
-sub process_email_search_result {
- my $job = shift;
- #warn "$me process_re_X $method for job $job\n" if $DEBUG;
-
- my $param = thaw(decode_base64(shift));
- warn Dumper($param) if $DEBUG;
-
- $param->{'job'} = $job;
-
- $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
- unless ref($param->{'payby'});
-
- my $error = FS::cust_main->email_search_result( $param );
- die $error if $error;
-
-}
-
-=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
-
-Performs a fuzzy (approximate) search and returns the matching FS::cust_main
-records. Currently, I<first>, I<last>, I<company> and/or I<address1> may be
-specified (the appropriate ship_ field is also searched).
-
-Additional options are the same as FS::Record::qsearch
-
-=cut
-
-sub fuzzy_search {
- my( $self, $fuzzy, $hash, @opt) = @_;
- #$self
- $hash ||= {};
- my @cust_main = ();
-
- check_and_rebuild_fuzzyfiles();
- foreach my $field ( keys %$fuzzy ) {
-
- my $all = $self->all_X($field);
- next unless scalar(@$all);
-
- my %match = ();
- $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
-
- my @fcust = ();
- foreach ( keys %match ) {
- push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
- push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
- }
- my %fsaw = ();
- push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
- }
-
- # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
- my %saw = ();
- @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
-
- @cust_main;
-
-}
-
-=item masked FIELD
-
-Returns a masked version of the named field
-
-=cut
-
-sub masked {
-my ($self,$field) = @_;
-
-# Show last four
-
-'x'x(length($self->getfield($field))-4).
- substr($self->getfield($field), (length($self->getfield($field))-4));
-
+ my $class = shift;
+ FS::cust_main::Search->search(@_);
}
=back
@@ -8229,435 +4257,15 @@ my ($self,$field) = @_;
=over 4
-=item smart_search OPTION => VALUE ...
-
-Accepts the following options: I<search>, the string to search for. The string
-will be searched for as a customer number, phone number, name or company name,
-as an exact, or, in some cases, a substring or fuzzy match (see the source code
-for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
-skip fuzzy matching when an exact match is found.
-
-Any additional options are treated as an additional qualifier on the search
-(i.e. I<agentnum>).
-
-Returns a (possibly empty) array of FS::cust_main objects.
-
-=cut
-
-sub smart_search {
- my %options = @_;
-
- #here is the agent virtualization
- my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
-
- my @cust_main = ();
-
- my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
- my $search = delete $options{'search'};
- ( my $alphanum_search = $search ) =~ s/\W//g;
-
- if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
-
- #false laziness w/Record::ut_phone
- my $phonen = "$1-$2-$3";
- $phonen .= " x$4" if $4;
-
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { %options },
- 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
- ' ( '.
- join(' OR ', map "$_ = '$phonen'",
- qw( daytime night fax
- ship_daytime ship_night ship_fax )
- ).
- ' ) '.
- " AND $agentnums_sql", #agent virtualization
- } );
-
- unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
- #try looking for matches with extensions unless one was specified
-
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { %options },
- 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
- ' ( '.
- join(' OR ', map "$_ LIKE '$phonen\%'",
- qw( daytime night
- ship_daytime ship_night )
- ).
- ' ) '.
- " AND $agentnums_sql", #agent virtualization
- } );
-
- }
-
- # custnum search (also try agent_custid), with some tweaking options if your
- # legacy cust "numbers" have letters
- }
-
- if ( $search =~ /^\s*(\d+)\s*$/
- || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
- && $search =~ /^\s*(\w\w?\d+)\s*$/
- )
- || ( $conf->exists('address1-search' )
- && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
- )
- )
- {
-
- my $num = $1;
-
- if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { 'custnum' => $num, %options },
- 'extra_sql' => " AND $agentnums_sql", #agent virtualization
- } );
- }
-
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { 'agent_custid' => $num, %options },
- 'extra_sql' => " AND $agentnums_sql", #agent virtualization
- } );
-
- if ( $conf->exists('address1-search') ) {
- my $len = length($num);
- $num = lc($num);
- foreach my $prefix ( '', 'ship_' ) {
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { %options, },
- 'extra_sql' =>
- ( keys(%options) ? ' AND ' : ' WHERE ' ).
- " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
- " AND $agentnums_sql",
- } );
- }
- }
-
- } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
-
- my($company, $last, $first) = ( $1, $2, $3 );
-
- # "Company (Last, First)"
- #this is probably something a browser remembered,
- #so just do an exact search (but case-insensitive, so USPS standardization
- #doesn't throw a wrench in the works)
-
- foreach my $prefix ( '', 'ship_' ) {
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { %options },
- 'extra_sql' =>
- ( keys(%options) ? ' AND ' : ' WHERE ' ).
- join(' AND ',
- " LOWER(${prefix}first) = ". dbh->quote(lc($first)),
- " LOWER(${prefix}last) = ". dbh->quote(lc($last)),
- " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
- $agentnums_sql,
- ),
- } );
- }
-
- } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
- # try (ship_){last,company}
-
- my $value = lc($1);
-
- # # remove "(Last, First)" in "Company (Last, First)", otherwise the
- # # full strings the browser remembers won't work
- # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
-
- use Lingua::EN::NameParse;
- my $NameParse = new Lingua::EN::NameParse(
- auto_clean => 1,
- allow_reversed => 1,
- );
-
- my($last, $first) = ( '', '' );
- #maybe disable this too and just rely on NameParse?
- if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
-
- ($last, $first) = ( $1, $2 );
-
- #} elsif ( $value =~ /^(.+)\s+(.+)$/ ) {
- } elsif ( ! $NameParse->parse($value) ) {
-
- my %name = $NameParse->components;
- $first = $name{'given_name_1'};
- $last = $name{'surname_1'};
-
- }
-
- if ( $first && $last ) {
-
- my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
-
- #exact
- my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
- $sql .= "
- ( ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
- OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
- )";
-
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => \%options,
- 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
- } );
-
- # or it just be something that was typed in... (try that in a sec)
-
- }
-
- my $q_value = dbh->quote($value);
-
- #exact
- my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
- $sql .= " ( LOWER(last) = $q_value
- OR LOWER(company) = $q_value
- OR LOWER(ship_last) = $q_value
- OR LOWER(ship_company) = $q_value
- ";
- $sql .= " OR LOWER(address1) = $q_value
- OR LOWER(ship_address1) = $q_value
- "
- if $conf->exists('address1-search');
- $sql .= " )";
-
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => \%options,
- 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
- } );
-
- #no exact match, trying substring/fuzzy
- #always do substring & fuzzy (unless they're explicity config'ed off)
- #getting complaints searches are not returning enough
- unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
-
- #still some false laziness w/search (was search/cust_main.cgi)
-
- #substring
-
- my @hashrefs = (
- { 'company' => { op=>'ILIKE', value=>"%$value%" }, },
- { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
- );
-
- if ( $first && $last ) {
-
- push @hashrefs,
- { 'first' => { op=>'ILIKE', value=>"%$first%" },
- 'last' => { op=>'ILIKE', value=>"%$last%" },
- },
- { 'ship_first' => { op=>'ILIKE', value=>"%$first%" },
- 'ship_last' => { op=>'ILIKE', value=>"%$last%" },
- },
- ;
-
- } else {
-
- push @hashrefs,
- { 'last' => { op=>'ILIKE', value=>"%$value%" }, },
- { 'ship_last' => { op=>'ILIKE', value=>"%$value%" }, },
- ;
- }
-
- if ( $conf->exists('address1-search') ) {
- push @hashrefs,
- { 'address1' => { op=>'ILIKE', value=>"%$value%" }, },
- { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
- ;
- }
-
- foreach my $hashref ( @hashrefs ) {
-
- push @cust_main, qsearch( {
- 'table' => 'cust_main',
- 'hashref' => { %$hashref,
- %options,
- },
- 'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
- } );
-
- }
-
- #fuzzy
- my @fuzopts = (
- \%options, #hashref
- '', #select
- " AND $agentnums_sql", #extra_sql #agent virtualization
- );
-
- if ( $first && $last ) {
- push @cust_main, FS::cust_main->fuzzy_search(
- { 'last' => $last, #fuzzy hashref
- 'first' => $first }, #
- @fuzopts
- );
- }
- foreach my $field ( 'last', 'company' ) {
- push @cust_main,
- FS::cust_main->fuzzy_search( { $field => $value }, @fuzopts );
- }
- if ( $conf->exists('address1-search') ) {
- push @cust_main,
- FS::cust_main->fuzzy_search( { 'address1' => $value }, @fuzopts );
- }
-
- }
-
- }
-
- #eliminate duplicates
- my %saw = ();
- @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
-
- @cust_main;
-
-}
-
-=item email_search
-
-Accepts the following options: I<email>, the email address to search for. The
-email address will be searched for as an email invoice destination and as an
-svc_acct account.
-
-#Any additional options are treated as an additional qualifier on the search
-#(i.e. I<agentnum>).
-
-Returns a (possibly empty) array of FS::cust_main objects (but usually just
-none or one).
-
-=cut
-
-sub email_search {
- my %options = @_;
-
- local($DEBUG) = 1;
-
- my $email = delete $options{'email'};
-
- #we're only being used by RT at the moment... no agent virtualization yet
- #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
-
- my @cust_main = ();
-
- if ( $email =~ /([^@]+)\@([^@]+)/ ) {
-
- my ( $user, $domain ) = ( $1, $2 );
-
- warn "$me smart_search: searching for $user in domain $domain"
- if $DEBUG;
-
- push @cust_main,
- map $_->cust_main,
- qsearch( {
- 'table' => 'cust_main_invoice',
- 'hashref' => { 'dest' => $email },
- }
- );
-
- push @cust_main,
- map $_->cust_main,
- grep $_,
- map $_->cust_svc->cust_pkg,
- qsearch( {
- 'table' => 'svc_acct',
- 'hashref' => { 'username' => $user, },
- 'extra_sql' =>
- 'AND ( SELECT domain FROM svc_domain
- WHERE svc_acct.domsvc = svc_domain.svcnum
- ) = '. dbh->quote($domain),
- }
- );
- }
-
- my %saw = ();
- @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
-
- warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
- if $DEBUG;
-
- @cust_main;
-
-}
-
-=item check_and_rebuild_fuzzyfiles
-
-=cut
-
-sub check_and_rebuild_fuzzyfiles {
- my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
- rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
-}
-
-=item rebuild_fuzzyfiles
-
-=cut
-
-sub rebuild_fuzzyfiles {
-
- use Fcntl qw(:flock);
-
- my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
- mkdir $dir, 0700 unless -d $dir;
-
- foreach my $fuzzy ( @fuzzyfields ) {
-
- open(LOCK,">>$dir/cust_main.$fuzzy")
- or die "can't open $dir/cust_main.$fuzzy: $!";
- flock(LOCK,LOCK_EX)
- or die "can't lock $dir/cust_main.$fuzzy: $!";
-
- open (CACHE,">$dir/cust_main.$fuzzy.tmp")
- or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
-
- foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
- my $sth = dbh->prepare("SELECT $field FROM cust_main".
- " WHERE $field != '' AND $field IS NOT NULL");
- $sth->execute or die $sth->errstr;
-
- while ( my $row = $sth->fetchrow_arrayref ) {
- print CACHE $row->[0]. "\n";
- }
-
- }
-
- close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
-
- rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
- close LOCK;
- }
-
-}
-
-=item all_X
-
-=cut
-
-sub all_X {
- my( $self, $field ) = @_;
- my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
- open(CACHE,"<$dir/cust_main.$field")
- or die "can't open $dir/cust_main.$field: $!";
- my @array = map { chomp; $_; } <CACHE>;
- close CACHE;
- \@array;
-}
-
=item append_fuzzyfiles FIRSTNAME LASTNAME COMPANY ADDRESS1
=cut
+use FS::cust_main::Search;
sub append_fuzzyfiles {
#my( $first, $last, $company ) = @_;
- &check_and_rebuild_fuzzyfiles;
+ FS::cust_main::Search::check_and_rebuild_fuzzyfiles();
use Fcntl qw(:flock);
@@ -8693,7 +4301,17 @@ sub batch_charge {
my $param = shift;
#warn join('-',keys %$param);
my $fh = $param->{filehandle};
- my @fields = @{$param->{fields}};
+ my $agentnum = $param->{agentnum};
+ my $format = $param->{format};
+
+ my $extra_sql = ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+ my @fields;
+ if ( $format eq 'simple' ) {
+ @fields = qw( custnum agent_custid amount pkg );
+ } else {
+ die "unknown format $format";
+ }
eval "use Text::CSV_XS;";
die $@ if $@;
@@ -8733,10 +4351,32 @@ sub batch_charge {
$row{$field} = shift @columns;
}
- my $cust_main = qsearchs('cust_main', { 'custnum' => $row{'custnum'} } );
+ if ( $row{custnum} && $row{agent_custid} ) {
+ dbh->rollback if $oldAutoCommit;
+ return "can't specify custnum with agent_custid $row{agent_custid}";
+ }
+
+ my %hash = ();
+ if ( $row{agent_custid} && $agentnum ) {
+ %hash = ( 'agent_custid' => $row{agent_custid},
+ 'agentnum' => $agentnum,
+ );
+ }
+
+ if ( $row{custnum} ) {
+ %hash = ( 'custnum' => $row{custnum} );
+ }
+
+ unless ( scalar(keys %hash) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't find customer without custnum or agent_custid and agentnum";
+ }
+
+ my $cust_main = qsearchs('cust_main', { %hash } );
unless ( $cust_main ) {
$dbh->rollback if $oldAutoCommit;
- return "unknown custnum $row{'custnum'}";
+ my $custnum = $row{custnum} || $row{agent_custid};
+ return "unknown custnum $custnum";
}
if ( $row{'amount'} > 0 ) {
@@ -8886,6 +4526,7 @@ I<$returnaddress> - the return address defaults to invoice_latexreturnaddress or
=cut
+# a lot like cust_bill::print_latex
sub generate_letter {
my ($self, $template, %options) = @_;
@@ -8936,8 +4577,13 @@ sub generate_letter {
$letter_data{returnaddress} = $retadd;
} elsif ( grep /\S/, $conf->config('company_address', $self->agentnum) ) {
$letter_data{returnaddress} =
- join( '\\*'."\n", map s/( {2,})/'~' x length($1)/eg,
- $conf->config('company_address', $self->agentnum)
+ join( "\n", map { s/( {2,})/'~' x length($1)/eg;
+ s/$/\\\\\*/;
+ $_;
+ }
+ ( $conf->config('company_name', $self->agentnum),
+ $conf->config('company_address', $self->agentnum),
+ )
);
} else {
$letter_data{returnaddress} = '~';
@@ -8949,6 +4595,17 @@ sub generate_letter {
$letter_data{company_name} = $conf->config('company_name', $self->agentnum);
my $dir = $FS::UID::conf_dir."/cache.". $FS::UID::datasrc;
+
+ my $lh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
+ DIR => $dir,
+ SUFFIX => '.eps',
+ UNLINK => 0,
+ ) or die "can't open temp file: $!\n";
+ print $lh $conf->config_binary('logo.eps', $self->agentnum)
+ or die "can't write temp file: $!\n";
+ close $lh;
+ $letter_data{'logo_file'} = $lh->filename;
+
my $fh = new File::Temp( TEMPLATE => 'letter.'. $self->custnum. '.XXXXXXXX',
DIR => $dir,
SUFFIX => '.tex',
@@ -8958,7 +4615,8 @@ sub generate_letter {
$letter_template->fill_in( OUTPUT => $fh, HASH => \%letter_data );
close $fh;
$fh->filename =~ /^(.*).tex$/ or die "unparsable filename: ". $fh->filename;
- return $1;
+ return ($1, $letter_data{'logo_file'});
+
}
=item print_ps TEMPLATE
@@ -8969,8 +4627,12 @@ Returns an postscript letter filled in from TEMPLATE, as a scalar.
sub print_ps {
my $self = shift;
- my $file = $self->generate_letter(@_);
- FS::Misc::generate_ps($file);
+ my($file, $lfile) = $self->generate_letter(@_);
+ my $ps = FS::Misc::generate_ps($file);
+ unlink($file.'.tex');
+ unlink($lfile);
+
+ $ps;
}
=item print TEMPLATE
@@ -9101,11 +4763,28 @@ sub process_bill_and_collect {
sub _upgrade_data { #class method
my ($class, %opts) = @_;
- my $sql = 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL';
- my $sth = dbh->prepare($sql) or die dbh->errstr;
- $sth->execute or die $sth->errstr;
+ my @statements = (
+ 'UPDATE h_cust_main SET paycvv = NULL WHERE paycvv IS NOT NULL',
+ 'UPDATE cust_main SET signupdate = (SELECT signupdate FROM h_cust_main WHERE signupdate IS NOT NULL AND h_cust_main.custnum = cust_main.custnum ORDER BY historynum DESC LIMIT 1) WHERE signupdate IS NULL',
+ );
+ # fix yyyy-m-dd formatted paydates
+ if ( driver_name =~ /^mysql$/i ) {
+ push @statements,
+ "UPDATE cust_main SET paydate = CONCAT( SUBSTRING(paydate FROM 1 FOR 5), '0', SUBSTRING(paydate FROM 6) ) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
+ }
+ else { # the SQL standard
+ push @statements,
+ "UPDATE cust_main SET paydate = SUBSTRING(paydate FROM 1 FOR 5) || '0' || SUBSTRING(paydate FROM 6) WHERE SUBSTRING(paydate FROM 7 FOR 1) = '-'";
+ }
+
+ foreach my $sql ( @statements ) {
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute or die $sth->errstr;
+ }
local($ignore_expired_card) = 1;
+ local($ignore_illegal_zip) = 1;
+ local($ignore_banned_card) = 1;
local($skip_fuzzyfiles) = 1;
$class->_upgrade_otaker(%opts);
diff --git a/FS/FS/cust_main/Billing.pm b/FS/FS/cust_main/Billing.pm
new file mode 100644
index 0000000..e004788
--- /dev/null
+++ b/FS/FS/cust_main/Billing.pm
@@ -0,0 +1,2256 @@
+package FS::cust_main::Billing;
+
+use strict;
+use vars qw( $conf $DEBUG $me );
+use Carp;
+use Data::Dumper;
+use List::Util qw( min );
+use FS::UID qw( dbh );
+use FS::Record qw( qsearch qsearchs dbdef );
+use FS::cust_bill;
+use FS::cust_bill_pkg;
+use FS::cust_bill_pkg_display;
+use FS::cust_bill_pay;
+use FS::cust_credit_bill;
+use FS::cust_pkg;
+use FS::cust_tax_adjustment;
+use FS::tax_rate;
+use FS::tax_rate_location;
+use FS::cust_bill_pkg_tax_location;
+use FS::cust_bill_pkg_tax_rate_location;
+use FS::part_event;
+use FS::part_event_condition;
+
+# 1 is mostly method/subroutine entry and options
+# 2 traces progress of some operations
+# 3 is even more information including possibly sensitive data
+$DEBUG = 0;
+$me = '[FS::cust_main::Billing]';
+
+install_callback FS::UID sub {
+ $conf = new FS::Conf;
+ #yes, need it for stuff below (prolly should be cached)
+};
+
+=head1 NAME
+
+FS::cust_main::Billing - Billing mixin for cust_main
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+These methods are available on FS::cust_main objects.
+
+=head1 METHODS
+
+=over 4
+
+=item bill_and_collect
+
+Cancels and suspends any packages due, generates bills, applies payments and
+credits, and applies collection events to run cards, send bills and notices,
+etc.
+
+By default, warns on errors and continues with the next operation (but see the
+"fatal" flag below).
+
+Options are passed as name-value pairs. Currently available options are:
+
+=over 4
+
+=item time
+
+Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
+
+ use Date::Parse;
+ ...
+ $cust_main->bill( 'time' => str2time('April 20th, 2001') );
+
+=item invoice_time
+
+Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item resetup
+
+If set true, re-charges setup fees.
+
+=item fatal
+
+If set any errors prevent subsequent operations from continusing. If set
+specifically to "return", returns the error (or false, if there is no error).
+Any other true value causes errors to die.
+
+=item debug
+
+Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+
+=item job
+
+Optional FS::queue entry to receive status updates.
+
+=back
+
+Options are passed to the B<bill> and B<collect> methods verbatim, so all
+options of those methods are also available.
+
+=cut
+
+sub bill_and_collect {
+ my( $self, %options ) = @_;
+
+ my $error;
+
+ #$options{actual_time} not $options{time} because freeside-daily -d is for
+ #pre-printing invoices
+
+ $options{'actual_time'} ||= time;
+ my $job = $options{'job'};
+
+ $job->update_statustext('0,cleaning expired packages') if $job;
+ $error = $self->cancel_expired_pkgs( $options{actual_time} );
+ if ( $error ) {
+ $error = "Error expiring custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+
+ $error = $self->suspend_adjourned_pkgs( $options{actual_time} );
+ if ( $error ) {
+ $error = "Error adjourning custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+
+ $job->update_statustext('20,billing packages') if $job;
+ $error = $self->bill( %options );
+ if ( $error ) {
+ $error = "Error billing custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+
+ $job->update_statustext('50,applying payments and credits') if $job;
+ $error = $self->apply_payments_and_credits;
+ if ( $error ) {
+ $error = "Error applying custnum ". $self->custnum. ": $error";
+ if ( $options{fatal} && $options{fatal} eq 'return' ) { return $error; }
+ elsif ( $options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+
+ $job->update_statustext('70,running collection events') if $job;
+ unless ( $conf->exists('cancelled_cust-noevents')
+ && ! $self->num_ncancelled_pkgs
+ ) {
+ $error = $self->collect( %options );
+ if ( $error ) {
+ $error = "Error collecting custnum ". $self->custnum. ": $error";
+ if ($options{fatal} && $options{fatal} eq 'return') { return $error; }
+ elsif ($options{fatal} ) { die $error; }
+ else { warn $error; }
+ }
+ }
+ $job->update_statustext('100,finished') if $job;
+
+ '';
+
+}
+
+sub cancel_expired_pkgs {
+ my ( $self, $time, %options ) = @_;
+
+ my @cancel_pkgs = $self->ncancelled_pkgs( {
+ 'extra_sql' => " AND expire IS NOT NULL AND expire > 0 AND expire <= $time "
+ } );
+
+ my @errors = ();
+
+ foreach my $cust_pkg ( @cancel_pkgs ) {
+ my $cpr = $cust_pkg->last_cust_pkg_reason('expire');
+ my $error = $cust_pkg->cancel($cpr ? ( 'reason' => $cpr->reasonnum,
+ 'reason_otaker' => $cpr->otaker
+ )
+ : ()
+ );
+ push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
+ }
+
+ scalar(@errors) ? join(' / ', @errors) : '';
+
+}
+
+sub suspend_adjourned_pkgs {
+ my ( $self, $time, %options ) = @_;
+
+ my @susp_pkgs = $self->ncancelled_pkgs( {
+ 'extra_sql' =>
+ " AND ( susp IS NULL OR susp = 0 )
+ AND ( ( bill IS NOT NULL AND bill != 0 AND bill < $time )
+ OR ( adjourn IS NOT NULL AND adjourn != 0 AND adjourn <= $time )
+ )
+ ",
+ } );
+
+ #only because there's no SQL test for is_prepaid :/
+ @susp_pkgs =
+ grep { ( $_->part_pkg->is_prepaid
+ && $_->bill
+ && $_->bill < $time
+ )
+ || ( $_->adjourn
+ && $_->adjourn <= $time
+ )
+
+ }
+ @susp_pkgs;
+
+ my @errors = ();
+
+ foreach my $cust_pkg ( @susp_pkgs ) {
+ my $cpr = $cust_pkg->last_cust_pkg_reason('adjourn')
+ if ($cust_pkg->adjourn && $cust_pkg->adjourn < $^T);
+ my $error = $cust_pkg->suspend($cpr ? ( 'reason' => $cpr->reasonnum,
+ 'reason_otaker' => $cpr->otaker
+ )
+ : ()
+ );
+ push @errors, 'pkgnum '.$cust_pkg->pkgnum.": $error" if $error;
+ }
+
+ scalar(@errors) ? join(' / ', @errors) : '';
+
+}
+
+=item bill OPTIONS
+
+Generates invoices (see L<FS::cust_bill>) for this customer. Usually used in
+conjunction with the collect method by calling B<bill_and_collect>.
+
+If there is an error, returns the error, otherwise returns false.
+
+Options are passed as name-value pairs. Currently available options are:
+
+=over 4
+
+=item resetup
+
+If set true, re-charges setup fees.
+
+=item recurring_only
+
+If set true then only bill recurring charges, not setup, usage, one time
+charges, etc.
+
+=item freq_override
+
+If set, then override the normal frequency and look for a part_pkg_discount
+to take at that frequency.
+
+=item time
+
+Bills the customer as if it were that time. Specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions. For example:
+
+ use Date::Parse;
+ ...
+ $cust_main->bill( 'time' => str2time('April 20th, 2001') );
+
+=item pkg_list
+
+An array ref of specific packages (objects) to attempt billing, instead trying all of them.
+
+ $cust_main->bill( pkg_list => [$pkg1, $pkg2] );
+
+=item not_pkgpart
+
+A hashref of pkgparts to exclude from this billing run (can also be specified as a comma-separated scalar).
+
+=item invoice_time
+
+Used in conjunction with the I<time> option, this option specifies the date of for the generated invoices. Other calculations, such as whether or not to generate the invoice in the first place, are not affected.
+
+=item cancel
+
+This boolean value informs the us that the package is being cancelled. This
+typically might mean not charging the normal recurring fee but only usage
+fees since the last billing. Setup charges may be charged. Not all package
+plans support this feature (they tend to charge 0).
+
+=item no_usage_reset
+
+Prevent the resetting of usage limits during this call.
+
+=item no_commit
+
+Do not save the generated bill in the database. Useful with return_bill
+
+=item return_bill
+
+A list reference on which the generated bill(s) will be returned.
+
+=item invoice_terms
+
+Optional terms to be printed on this invoice. Otherwise, customer-specific
+terms or the default terms are used.
+
+=back
+
+=cut
+
+sub bill {
+ my( $self, %options ) = @_;
+
+ return '' if $self->payby eq 'COMP';
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ warn "$me bill customer ". $self->custnum. "\n"
+ if $DEBUG;
+
+ my $time = $options{'time'} || time;
+ my $invoice_time = $options{'invoice_time'} || $time;
+
+ $options{'not_pkgpart'} ||= {};
+ $options{'not_pkgpart'} = { map { $_ => 1 }
+ split(/\s*,\s*/, $options{'not_pkgpart'})
+ }
+ unless ref($options{'not_pkgpart'});
+
+ 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;
+
+ warn "$me acquiring lock on customer ". $self->custnum. "\n"
+ if $DEBUG;
+
+ $self->select_for_update; #mutex
+
+ warn "$me running pre-bill events for customer ". $self->custnum. "\n"
+ if $DEBUG;
+
+ my $error = $self->do_cust_event(
+ 'debug' => ( $options{'debug'} || 0 ),
+ 'time' => $invoice_time,
+ 'check_freq' => $options{'check_freq'},
+ 'stage' => 'pre-bill',
+ )
+ unless $options{no_commit};
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return $error;
+ }
+
+ warn "$me done running pre-bill events for customer ". $self->custnum. "\n"
+ if $DEBUG;
+
+ #keep auto-charge and non-auto-charge line items separate
+ my @passes = ( '', 'no_auto' );
+
+ my %cust_bill_pkg = map { $_ => [] } @passes;
+
+ ###
+ # find the packages which are due for billing, find out how much they are
+ # & generate invoice database.
+ ###
+
+ my %total_setup = map { my $z = 0; $_ => \$z; } @passes;
+ my %total_recur = map { my $z = 0; $_ => \$z; } @passes;
+
+ my %taxlisthash = map { $_ => {} } @passes;
+
+ my @precommit_hooks = ();
+
+ $options{'pkg_list'} ||= [ $self->ncancelled_pkgs ]; #param checks?
+ foreach my $cust_pkg ( @{ $options{'pkg_list'} } ) {
+
+ next if $options{'not_pkgpart'}->{$cust_pkg->pkgpart};
+
+ warn " bill package ". $cust_pkg->pkgnum. "\n" if $DEBUG > 1;
+
+ #? to avoid use of uninitialized value errors... ?
+ $cust_pkg->setfield('bill', '')
+ unless defined($cust_pkg->bill);
+
+ #my $part_pkg = $cust_pkg->part_pkg;
+
+ my $real_pkgpart = $cust_pkg->pkgpart;
+ my %hash = $cust_pkg->hash;
+
+ # we could implement this bit as FS::part_pkg::has_hidden, but we already
+ # suffer from performance issues
+ $options{has_hidden} = 0;
+ my @part_pkg = $cust_pkg->part_pkg->self_and_bill_linked;
+ $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
+
+ foreach my $part_pkg ( @part_pkg ) {
+
+ $cust_pkg->set($_, $hash{$_}) foreach qw ( setup last_bill bill );
+
+ my $pass = ($cust_pkg->no_auto || $part_pkg->no_auto) ? 'no_auto' : '';
+
+ my $error =
+ $self->_make_lines( 'part_pkg' => $part_pkg,
+ 'cust_pkg' => $cust_pkg,
+ 'precommit_hooks' => \@precommit_hooks,
+ 'line_items' => $cust_bill_pkg{$pass},
+ 'setup' => $total_setup{$pass},
+ 'recur' => $total_recur{$pass},
+ 'tax_matrix' => $taxlisthash{$pass},
+ 'time' => $time,
+ 'real_pkgpart' => $real_pkgpart,
+ 'options' => \%options,
+ );
+ if ($error) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return $error;
+ }
+
+ } #foreach my $part_pkg
+
+ } #foreach my $cust_pkg
+
+ #if the customer isn't on an automatic payby, everything can go on a single
+ #invoice anyway?
+ #if ( $cust_main->payby !~ /^(CARD|CHEK)$/ ) {
+ #merge everything into one list
+ #}
+
+ foreach my $pass (@passes) { # keys %cust_bill_pkg ) {
+
+ my @cust_bill_pkg = _omit_zero_value_bundles(@{ $cust_bill_pkg{$pass} });
+
+ next unless @cust_bill_pkg; #don't create an invoice w/o line items
+
+ warn "$me billing pass $pass\n"
+ #.Dumper(\@cust_bill_pkg)."\n"
+ if $DEBUG > 2;
+
+ if ( scalar( grep { $_->recur && $_->recur > 0 } @cust_bill_pkg) ||
+ !$conf->exists('postal_invoice-recurring_only')
+ )
+ {
+
+ my $postal_pkg = $self->charge_postal_fee();
+ if ( $postal_pkg && !ref( $postal_pkg ) ) {
+
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return "can't charge postal invoice fee for customer ".
+ $self->custnum. ": $postal_pkg";
+
+ } elsif ( $postal_pkg ) {
+
+ my $real_pkgpart = $postal_pkg->pkgpart;
+ # we could implement this bit as FS::part_pkg::has_hidden, but we already
+ # suffer from performance issues
+ $options{has_hidden} = 0;
+ my @part_pkg = $postal_pkg->part_pkg->self_and_bill_linked;
+ $options{has_hidden} = 1 if ($part_pkg[1] && $part_pkg[1]->hidden);
+
+ foreach my $part_pkg ( @part_pkg ) {
+ my %postal_options = %options;
+ delete $postal_options{cancel};
+ my $error =
+ $self->_make_lines( 'part_pkg' => $part_pkg,
+ 'cust_pkg' => $postal_pkg,
+ 'precommit_hooks' => \@precommit_hooks,
+ 'line_items' => \@cust_bill_pkg,
+ 'setup' => $total_setup{$pass},
+ 'recur' => $total_recur{$pass},
+ 'tax_matrix' => $taxlisthash{$pass},
+ 'time' => $time,
+ 'real_pkgpart' => $real_pkgpart,
+ 'options' => \%postal_options,
+ );
+ if ($error) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return $error;
+ }
+ }
+
+ # it's silly to have a zero value postal_pkg, but....
+ @cust_bill_pkg = _omit_zero_value_bundles(@cust_bill_pkg);
+
+ }
+
+ }
+
+ my $listref_or_error =
+ $self->calculate_taxes( \@cust_bill_pkg, $taxlisthash{$pass}, $invoice_time);
+
+ unless ( ref( $listref_or_error ) ) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return $listref_or_error;
+ }
+
+ foreach my $taxline ( @$listref_or_error ) {
+ ${ $total_setup{$pass} } =
+ sprintf('%.2f', ${ $total_setup{$pass} } + $taxline->setup );
+ push @cust_bill_pkg, $taxline;
+ }
+
+ #add tax adjustments
+ warn "adding tax adjustments...\n" if $DEBUG > 2;
+ foreach my $cust_tax_adjustment (
+ qsearch('cust_tax_adjustment', { 'custnum' => $self->custnum,
+ 'billpkgnum' => '',
+ }
+ )
+ ) {
+
+ my $tax = sprintf('%.2f', $cust_tax_adjustment->amount );
+
+ my $itemdesc = $cust_tax_adjustment->taxname;
+ $itemdesc = '' if $itemdesc eq 'Tax';
+
+ push @cust_bill_pkg, new FS::cust_bill_pkg {
+ 'pkgnum' => 0,
+ 'setup' => $tax,
+ 'recur' => 0,
+ 'sdate' => '',
+ 'edate' => '',
+ 'itemdesc' => $itemdesc,
+ 'itemcomment' => $cust_tax_adjustment->comment,
+ 'cust_tax_adjustment' => $cust_tax_adjustment,
+ #'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+ };
+
+ }
+
+ my $charged = sprintf('%.2f', ${ $total_setup{$pass} } + ${ $total_recur{$pass} } );
+
+ my @cust_bill = $self->cust_bill;
+ my $balance = $self->balance;
+ my $previous_balance = scalar(@cust_bill)
+ ? ( $cust_bill[$#cust_bill]->billing_balance || 0 )
+ : 0;
+
+ $previous_balance += $cust_bill[$#cust_bill]->charged
+ if scalar(@cust_bill);
+ #my $balance_adjustments =
+ # sprintf('%.2f', $balance - $prior_prior_balance - $prior_charged);
+
+ warn "creating the new invoice\n" if $DEBUG;
+ #create the new invoice
+ my $cust_bill = new FS::cust_bill ( {
+ 'custnum' => $self->custnum,
+ '_date' => ( $invoice_time ),
+ 'charged' => $charged,
+ 'billing_balance' => $balance,
+ 'previous_balance' => $previous_balance,
+ 'invoice_terms' => $options{'invoice_terms'},
+ 'cust_bill_pkg' => \@cust_bill_pkg,
+ } );
+ $error = $cust_bill->insert unless $options{no_commit};
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return "can't create invoice for customer #". $self->custnum. ": $error";
+ }
+ push @{$options{return_bill}}, $cust_bill if $options{return_bill};
+
+ } #foreach my $pass ( keys %cust_bill_pkg )
+
+ foreach my $hook ( @precommit_hooks ) {
+ eval {
+ &{$hook}; #($self) ?
+ } unless $options{no_commit};
+ if ( $@ ) {
+ $dbh->rollback if $oldAutoCommit && !$options{no_commit};
+ return "$@ running precommit hook $hook\n";
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit && !$options{no_commit};
+
+ ''; #no error
+}
+
+#discard bundled packages of 0 value
+sub _omit_zero_value_bundles {
+
+ my @cust_bill_pkg = ();
+ my @cust_bill_pkg_bundle = ();
+ my $sum = 0;
+
+ foreach my $cust_bill_pkg ( @_ ) {
+ if (scalar(@cust_bill_pkg_bundle) && !$cust_bill_pkg->pkgpart_override) {
+ push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
+ @cust_bill_pkg_bundle = ();
+ $sum = 0;
+ }
+ $sum += $cust_bill_pkg->setup + $cust_bill_pkg->recur;
+ push @cust_bill_pkg_bundle, $cust_bill_pkg;
+ }
+ push @cust_bill_pkg, @cust_bill_pkg_bundle if $sum > 0;
+
+ (@cust_bill_pkg);
+
+}
+
+=item calculate_taxes LINEITEMREF TAXHASHREF INVOICE_TIME
+
+This is a weird one. Perhaps it should not even be exposed.
+
+Generates tax line items (see L<FS::cust_bill_pkg>) for this customer.
+Usually used internally by bill method B<bill>.
+
+If there is an error, returns the error, otherwise returns reference to a
+list of line items suitable for insertion.
+
+=over 4
+
+=item LINEITEMREF
+
+An array ref of the line items being billed.
+
+=item TAXHASHREF
+
+A strange beast. The keys to this hash are internal identifiers consisting
+of the name of the tax object type, a space, and its unique identifier ( e.g.
+ 'cust_main_county 23' ). The values of the hash are listrefs. The first
+item in the list is the tax object. The remaining items are either line
+items or floating point values (currency amounts).
+
+The taxes are calculated on this entity. Calculated exemption records are
+transferred to the LINEITEMREF items on the assumption that they are related.
+
+Read the source.
+
+=item INVOICE_TIME
+
+This specifies the date appearing on the associated invoice. Some
+jurisdictions (i.e. Texas) have tax exemptions which are date sensitive.
+
+=back
+
+=cut
+
+sub calculate_taxes {
+ my ($self, $cust_bill_pkg, $taxlisthash, $invoice_time) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ warn "$me calculate_taxes\n"
+ #.Dumper($self, $cust_bill_pkg, $taxlisthash, $invoice_time). "\n"
+ if $DEBUG > 2;
+
+ my @tax_line_items = ();
+
+ # keys are tax names (as printed on invoices / itemdesc )
+ # values are listrefs of taxlisthash keys (internal identifiers)
+ my %taxname = ();
+
+ # keys are taxlisthash keys (internal identifiers)
+ # values are (cumulative) amounts
+ my %tax = ();
+
+ # keys are taxlisthash keys (internal identifiers)
+ # values are listrefs 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
+ my %tax_rate_location = ();
+
+ foreach my $tax ( keys %$taxlisthash ) {
+ my $tax_object = shift @{ $taxlisthash->{$tax} };
+ warn "found ". $tax_object->taxname. " as $tax\n" if $DEBUG > 2;
+ warn " ". join('/', @{ $taxlisthash->{$tax} } ). "\n" if $DEBUG > 2;
+ my $hashref_or_error =
+ $tax_object->taxline( $taxlisthash->{$tax},
+ 'custnum' => $self->custnum,
+ 'invoice_time' => $invoice_time
+ );
+ return $hashref_or_error unless ref($hashref_or_error);
+
+ unshift @{ $taxlisthash->{$tax} }, $tax_object;
+
+ my $name = $hashref_or_error->{'name'};
+ my $amount = $hashref_or_error->{'amount'};
+
+ #warn "adding $amount as $name\n";
+ $taxname{ $name } ||= [];
+ push @{ $taxname{ $name } }, $tax;
+
+ $tax{ $tax } += $amount;
+
+ $tax_location{ $tax } ||= [];
+ if ( $tax_object->get('pkgnum') || $tax_object->get('locationnum') ) {
+ push @{ $tax_location{ $tax } },
+ {
+ 'taxnum' => $tax_object->taxnum,
+ 'taxtype' => ref($tax_object),
+ 'pkgnum' => $tax_object->get('pkgnum'),
+ 'locationnum' => $tax_object->get('locationnum'),
+ 'amount' => sprintf('%.2f', $amount ),
+ };
+ }
+
+ $tax_rate_location{ $tax } ||= [];
+ if ( ref($tax_object) eq 'FS::tax_rate' ) {
+ my $taxratelocationnum =
+ $tax_object->tax_rate_location->taxratelocationnum;
+ push @{ $tax_rate_location{ $tax } },
+ {
+ 'taxnum' => $tax_object->taxnum,
+ 'taxtype' => ref($tax_object),
+ 'amount' => sprintf('%.2f', $amount ),
+ 'locationtaxid' => $tax_object->location,
+ 'taxratelocationnum' => $taxratelocationnum,
+ };
+ }
+
+ }
+
+ #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';
+ push @{ $packagemap{$_->pkgnum}->_cust_tax_exempt_pkg },
+ splice( @{ $_->_cust_tax_exempt_pkg } );
+ }
+ }
+
+ #consolidate and create tax line items
+ warn "consolidating and generating...\n" if $DEBUG > 2;
+ foreach my $taxname ( keys %taxname ) {
+ my $tax = 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};
+ push @cust_bill_pkg_tax_location,
+ map { new FS::cust_bill_pkg_tax_location $_ }
+ @{ $tax_location{ $taxitem } };
+ push @cust_bill_pkg_tax_rate_location,
+ map { new FS::cust_bill_pkg_tax_rate_location $_ }
+ @{ $tax_rate_location{ $taxitem } };
+ }
+ next unless $tax;
+
+ $tax = sprintf('%.2f', $tax );
+
+ my $pkg_category = qsearchs( 'pkg_category', { 'categoryname' => $taxname,
+ 'disabled' => '',
+ },
+ );
+
+ my @display = ();
+ if ( $pkg_category and
+ $conf->config('invoice_latexsummary') ||
+ $conf->config('invoice_htmlsummary')
+ )
+ {
+
+ my %hash = ( 'section' => $pkg_category->categoryname );
+ push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+
+ }
+
+ push @tax_line_items, new FS::cust_bill_pkg {
+ 'pkgnum' => 0,
+ 'setup' => $tax,
+ 'recur' => 0,
+ 'sdate' => '',
+ 'edate' => '',
+ 'itemdesc' => $taxname,
+ 'display' => \@display,
+ 'cust_bill_pkg_tax_location' => \@cust_bill_pkg_tax_location,
+ 'cust_bill_pkg_tax_rate_location' => \@cust_bill_pkg_tax_rate_location,
+ };
+
+ }
+
+ \@tax_line_items;
+}
+
+sub _make_lines {
+ my ($self, %params) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my $part_pkg = $params{part_pkg} or die "no part_pkg specified";
+ my $cust_pkg = $params{cust_pkg} or die "no cust_pkg specified";
+ my $precommit_hooks = $params{precommit_hooks} or die "no package specified";
+ my $cust_bill_pkgs = $params{line_items} or die "no line buffer specified";
+ my $total_setup = $params{setup} or die "no setup accumulator specified";
+ my $total_recur = $params{recur} or die "no recur accumulator specified";
+ my $taxlisthash = $params{tax_matrix} or die "no tax accumulator specified";
+ my $time = $params{'time'} or die "no time specified";
+ my (%options) = %{$params{options}};
+
+ my $dbh = dbh;
+ my $real_pkgpart = $params{real_pkgpart};
+ my %hash = $cust_pkg->hash;
+ my $old_cust_pkg = new FS::cust_pkg \%hash;
+
+ my @details = ();
+ my @discounts = ();
+ my $lineitems = 0;
+
+ $cust_pkg->pkgpart($part_pkg->pkgpart);
+
+ ###
+ # bill setup
+ ###
+
+ my $setup = 0;
+ my $unitsetup = 0;
+ if ( $options{'resetup'}
+ || ( ! $cust_pkg->setup
+ && ( ! $cust_pkg->start_date
+ || $cust_pkg->start_date <= $time
+ )
+ && ( ! $conf->exists('disable_setup_suspended_pkgs')
+ || ( $conf->exists('disable_setup_suspended_pkgs') &&
+ ! $cust_pkg->getfield('susp')
+ )
+ )
+ )
+ and !$options{recurring_only}
+ )
+ {
+
+ warn " bill setup\n" if $DEBUG > 1;
+ $lineitems++;
+
+ $setup = eval { $cust_pkg->calc_setup( $time, \@details ) };
+ return "$@ running calc_setup for $cust_pkg\n"
+ if $@;
+
+ $unitsetup = $cust_pkg->part_pkg->unit_setup || $setup; #XXX uuh
+
+ $cust_pkg->setfield('setup', $time)
+ unless $cust_pkg->setup;
+ #do need it, but it won't get written to the db
+ #|| $cust_pkg->pkgpart != $real_pkgpart;
+
+ $cust_pkg->setfield('start_date', '')
+ if $cust_pkg->start_date;
+
+ }
+
+ ###
+ # bill recurring fee
+ ###
+
+ #XXX unit stuff here too
+ my $recur = 0;
+ my $unitrecur = 0;
+ my $sdate;
+ if ( ! $cust_pkg->start_date
+ and ( ! $cust_pkg->susp || $part_pkg->option('suspend_bill') )
+ and
+ ( $part_pkg->freq ne '0' && ( $cust_pkg->bill || 0 ) <= $time )
+ || ( $part_pkg->plan eq 'voip_cdr'
+ && $part_pkg->option('bill_every_call')
+ )
+ || $options{cancel}
+ ) {
+
+ # XXX should this be a package event? probably. events are called
+ # at collection time at the moment, though...
+ $part_pkg->reset_usage($cust_pkg, 'debug'=>$DEBUG)
+ if $part_pkg->can('reset_usage') && !$options{'no_usage_reset'};
+ #don't want to reset usage just cause we want a line item??
+ #&& $part_pkg->pkgpart == $real_pkgpart;
+
+ warn " bill recur\n" if $DEBUG > 1;
+ $lineitems++;
+
+ # XXX shared with $recur_prog
+ $sdate = ( $options{cancel} ? $cust_pkg->last_bill : $cust_pkg->bill )
+ || $cust_pkg->setup
+ || $time;
+
+ #over two params! lets at least switch to a hashref for the rest...
+ my $increment_next_bill = ( $part_pkg->freq ne '0'
+ && ( $cust_pkg->getfield('bill') || 0 ) <= $time
+ && !$options{cancel}
+ );
+ my %param = ( 'precommit_hooks' => $precommit_hooks,
+ 'increment_next_bill' => $increment_next_bill,
+ 'discounts' => \@discounts,
+ 'real_pkgpart' => $real_pkgpart,
+ 'freq_override' => $options{freq_override} || '',
+ );
+
+ my $method = $options{cancel} ? 'calc_cancel' : 'calc_recur';
+
+ # There may be some part_pkg for which this is wrong. Only those
+ # which can_discount are supported.
+
+ $recur = eval { $cust_pkg->$method( \$sdate, \@details, \%param ) };
+ return "$@ running $method for $cust_pkg\n"
+ if ( $@ );
+
+ if ( $increment_next_bill ) {
+
+ my $next_bill = $part_pkg->add_freq($sdate, $options{freq_override} || 0);
+ return "unparsable frequency: ". $part_pkg->freq
+ if $next_bill == -1;
+
+ #pro-rating magic - if $recur_prog fiddled $sdate, want to use that
+ # only for figuring next bill date, nothing else, so, reset $sdate again
+ # here
+ $sdate = $cust_pkg->bill || $cust_pkg->setup || $time;
+ #no need, its in $hash{last_bill}# my $last_bill = $cust_pkg->last_bill;
+ $cust_pkg->last_bill($sdate);
+
+ $cust_pkg->setfield('bill', $next_bill );
+
+ }
+
+ }
+
+ warn "\$setup is undefined" unless defined($setup);
+ warn "\$recur is undefined" unless defined($recur);
+ warn "\$cust_pkg->bill is undefined" unless defined($cust_pkg->bill);
+
+ ###
+ # If there's line items, create em cust_bill_pkg records
+ # If $cust_pkg has been modified, update it (if we're a real pkgpart)
+ ###
+
+ if ( $lineitems ) {
+
+ if ( $cust_pkg->modified && $cust_pkg->pkgpart == $real_pkgpart ) {
+ # hmm.. and if just the options are modified in some weird price plan?
+
+ warn " package ". $cust_pkg->pkgnum. " modified; updating\n"
+ if $DEBUG >1;
+
+ my $error = $cust_pkg->replace( $old_cust_pkg,
+ 'options' => { $cust_pkg->options },
+ )
+ unless $options{no_commit};
+ return "Error modifying pkgnum ". $cust_pkg->pkgnum. ": $error"
+ if $error; #just in case
+ }
+
+ $setup = sprintf( "%.2f", $setup );
+ $recur = sprintf( "%.2f", $recur );
+ if ( $setup < 0 && ! $conf->exists('allow_negative_charges') ) {
+ return "negative setup $setup for pkgnum ". $cust_pkg->pkgnum;
+ }
+ if ( $recur < 0 && ! $conf->exists('allow_negative_charges') ) {
+ return "negative recur $recur for pkgnum ". $cust_pkg->pkgnum;
+ }
+
+ if ( $setup != 0 ||
+ $recur != 0 ||
+ !$part_pkg->hidden && $options{has_hidden} ) #include some $0 lines
+ {
+
+ warn " charges (setup=$setup, recur=$recur); adding line items\n"
+ if $DEBUG > 1;
+
+ my @cust_pkg_detail = map { $_->detail } $cust_pkg->cust_pkg_detail('I');
+ if ( $DEBUG > 1 ) {
+ warn " adding customer package invoice detail: $_\n"
+ foreach @cust_pkg_detail;
+ }
+ push @details, @cust_pkg_detail;
+
+ my $cust_bill_pkg = new FS::cust_bill_pkg {
+ 'pkgnum' => $cust_pkg->pkgnum,
+ 'setup' => $setup,
+ 'unitsetup' => $unitsetup,
+ 'recur' => $recur,
+ 'unitrecur' => $unitrecur,
+ 'quantity' => $cust_pkg->quantity,
+ 'details' => \@details,
+ 'discounts' => \@discounts,
+ 'hidden' => $part_pkg->hidden,
+ 'freq' => $part_pkg->freq,
+ };
+
+ if ( $part_pkg->option('recur_temporality', 1) eq 'preceding' ) {
+ $cust_bill_pkg->sdate( $hash{last_bill} );
+ $cust_bill_pkg->edate( $sdate - 86399 ); #60s*60m*24h-1
+ $cust_bill_pkg->edate( $time ) if $options{cancel};
+ } else { #if ( $part_pkg->option('recur_temporality', 1) eq 'upcoming' ) {
+ $cust_bill_pkg->sdate( $sdate );
+ $cust_bill_pkg->edate( $cust_pkg->bill );
+ #$cust_bill_pkg->edate( $time ) if $options{cancel};
+ }
+
+ $cust_bill_pkg->pkgpart_override($part_pkg->pkgpart)
+ unless $part_pkg->pkgpart == $real_pkgpart;
+
+ $$total_setup += $setup;
+ $$total_recur += $recur;
+
+ ###
+ # handle taxes
+ ###
+
+ my $error =
+ $self->_handle_taxes($part_pkg, $taxlisthash, $cust_bill_pkg, $cust_pkg, $options{invoice_time}, $real_pkgpart, \%options);
+ return $error if $error;
+
+ push @$cust_bill_pkgs, $cust_bill_pkg;
+
+ } #if $setup != 0 || $recur != 0
+
+ } #if $line_items
+
+ '';
+
+}
+
+sub _handle_taxes {
+ my $self = shift;
+ my $part_pkg = shift;
+ my $taxlisthash = shift;
+ my $cust_bill_pkg = shift;
+ my $cust_pkg = shift;
+ my $invoice_time = shift;
+ my $real_pkgpart = shift;
+ my $options = shift;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my %cust_bill_pkg = ();
+ my %taxes = ();
+
+ my @classes;
+ #push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->type eq 'U';
+ push @classes, $cust_bill_pkg->usage_classes if $cust_bill_pkg->usage;
+ push @classes, 'setup' if ($cust_bill_pkg->setup && !$options->{cancel});
+ push @classes, 'recur' if ($cust_bill_pkg->recur && !$options->{cancel});
+
+ if ( $self->tax !~ /Y/i && $self->payby ne 'COMP' ) {
+
+ if ( $conf->exists('enable_taxproducts')
+ && ( scalar($part_pkg->part_pkg_taxoverride)
+ || $part_pkg->has_taxproduct
+ )
+ )
+ {
+
+ 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;
+ }
+
+ } else {
+
+ my @loc_keys = qw( city county state country );
+ my %taxhash;
+ if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+ my $cust_location = $cust_pkg->cust_location;
+ %taxhash = map { $_ => $cust_location->$_() } @loc_keys;
+ } else {
+ my $prefix =
+ ( $conf->exists('tax-ship_address') && length($self->ship_last) )
+ ? 'ship_'
+ : '';
+ %taxhash = map { $_ => $self->get("$prefix$_") } @loc_keys;
+ }
+
+ $taxhash{'taxclass'} = $part_pkg->taxclass;
+
+ my @taxes = ();
+ my %taxhash_elim = %taxhash;
+ my @elim = qw( city county state );
+ do {
+
+ #first try a match with taxclass
+ @taxes = qsearch( 'cust_main_county', \%taxhash_elim );
+
+ if ( !scalar(@taxes) && $taxhash_elim{'taxclass'} ) {
+ #then try a match without taxclass
+ my %no_taxclass = %taxhash_elim;
+ $no_taxclass{ 'taxclass' } = '';
+ @taxes = qsearch( 'cust_main_county', \%no_taxclass );
+ }
+
+ $taxhash_elim{ shift(@elim) } = '';
+
+ } while ( !scalar(@taxes) && scalar(@elim) );
+
+ @taxes = grep { ! $_->taxname or ! $self->tax_exemption($_->taxname) }
+ @taxes
+ if $self->cust_main_exemption; #just to be safe
+
+ if ( $conf->exists('tax-pkg_address') && $cust_pkg->locationnum ) {
+ foreach (@taxes) {
+ $_->set('pkgnum', $cust_pkg->pkgnum );
+ $_->set('locationnum', $cust_pkg->locationnum );
+ }
+ }
+
+ $taxes{''} = [ @taxes ];
+ $taxes{'setup'} = [ @taxes ];
+ $taxes{'recur'} = [ @taxes ];
+ $taxes{$_} = [ @taxes ] foreach (@classes);
+
+ # # maybe eliminate this entirely, along with all the 0% records
+ # unless ( @taxes ) {
+ # return
+ # "fatal: can't find tax rate for state/county/country/taxclass ".
+ # join('/', map $taxhash{$_}, qw(state county country taxclass) );
+ # }
+
+ } #if $conf->exists('enable_taxproducts') ...
+
+ }
+
+ my @display = ();
+ my $separate = $conf->exists('separate_usage');
+ my $temp_pkg = new FS::cust_pkg { pkgpart => $real_pkgpart };
+ my $usage_mandate = $temp_pkg->part_pkg->option('usage_mandate', 'Hush!');
+ my $section = $temp_pkg->part_pkg->categoryname;
+ if ( $separate || $section || $usage_mandate ) {
+
+ my %hash = ( 'section' => $section );
+
+ $section = $temp_pkg->part_pkg->option('usage_section', 'Hush!');
+ my $summary = $temp_pkg->part_pkg->option('summarize_usage', 'Hush!');
+ if ( $separate ) {
+ push @display, new FS::cust_bill_pkg_display { type => 'S', %hash };
+ push @display, new FS::cust_bill_pkg_display { type => 'R', %hash };
+ } else {
+ push @display, new FS::cust_bill_pkg_display
+ { type => '',
+ %hash,
+ ( ( $usage_mandate ) ? ( 'summary' => 'Y' ) : () ),
+ };
+ }
+
+ if ($separate && $section && $summary) {
+ push @display, new FS::cust_bill_pkg_display { type => 'U',
+ summary => 'Y',
+ %hash,
+ };
+ }
+ if ($usage_mandate || $section && $summary) {
+ $hash{post_total} = 'Y';
+ }
+
+ if ($separate || $usage_mandate) {
+ $hash{section} = $section if ($separate || $usage_mandate);
+ push @display, new FS::cust_bill_pkg_display { type => 'U', %hash };
+ }
+
+ }
+ $cust_bill_pkg->set('display', \@display);
+
+ my %tax_cust_bill_pkg = $cust_bill_pkg->disintegrate;
+ foreach my $key (keys %tax_cust_bill_pkg) {
+ my @taxes = @{ $taxes{$key} || [] };
+ my $tax_cust_bill_pkg = $tax_cust_bill_pkg{$key};
+
+ my %localtaxlisthash = ();
+ foreach my $tax ( @taxes ) {
+
+ my $taxname = ref( $tax ). ' '. $tax->taxnum;
+# $taxname .= ' pkgnum'. $cust_pkg->pkgnum.
+# ' locationnum'. $cust_pkg->locationnum
+# if $conf->exists('tax-pkg_address') && $cust_pkg->locationnum;
+
+ $taxlisthash->{ $taxname } ||= [ $tax ];
+ push @{ $taxlisthash->{ $taxname } }, $tax_cust_bill_pkg;
+
+ $localtaxlisthash{ $taxname } ||= [ $tax ];
+ push @{ $localtaxlisthash{ $taxname } }, $tax_cust_bill_pkg;
+
+ }
+
+ warn "finding taxed taxes...\n" if $DEBUG > 2;
+ foreach my $tax ( keys %localtaxlisthash ) {
+ my $tax_object = shift @{ $localtaxlisthash{$tax} };
+ warn "found possible taxed tax ". $tax_object->taxname. " we call $tax\n"
+ if $DEBUG > 2;
+ next unless $tax_object->can('tax_on_tax');
+
+ foreach my $tot ( $tax_object->tax_on_tax( $self ) ) {
+ my $totname = ref( $tot ). ' '. $tot->taxnum;
+
+ warn "checking $totname which we call ". $tot->taxname. " as applicable\n"
+ if $DEBUG > 2;
+ next unless exists( $localtaxlisthash{ $totname } ); # only increase
+ # existing taxes
+ warn "adding $totname to taxed taxes\n" if $DEBUG > 2;
+ my $hashref_or_error =
+ $tax_object->taxline( $localtaxlisthash{$tax},
+ 'custnum' => $self->custnum,
+ 'invoice_time' => $invoice_time,
+ );
+ return $hashref_or_error
+ unless ref($hashref_or_error);
+
+ $taxlisthash->{ $totname } ||= [ $tot ];
+ push @{ $taxlisthash->{ $totname } }, $hashref_or_error->{amount};
+
+ }
+ }
+
+ }
+
+ '';
+}
+
+sub _gather_taxes {
+ my $self = shift;
+ my $part_pkg = shift;
+ my $class = shift;
+ my $cust_pkg = shift;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my $geocode;
+ if ( $cust_pkg->locationnum && $conf->exists('tax-pkg_address') ) {
+ $geocode = $cust_pkg->cust_location->geocode('cch');
+ } else {
+ $geocode = $self->geocode('cch');
+ }
+
+ my @taxes = ();
+
+ my @taxclassnums = map { $_->taxclassnum }
+ $part_pkg->part_pkg_taxoverride($class);
+
+ unless (@taxclassnums) {
+ @taxclassnums = map { $_->taxclassnum }
+ grep { $_->taxable eq 'Y' }
+ $part_pkg->part_pkg_taxrate('cch', $geocode, $class);
+ }
+ warn "Found taxclassnum values of ". join(',', @taxclassnums)
+ if $DEBUG;
+
+ my $extra_sql =
+ "AND (".
+ join(' OR ', map { "taxclassnum = $_" } @taxclassnums ). ")";
+
+ @taxes = qsearch({ 'table' => 'tax_rate',
+ 'hashref' => { 'geocode' => $geocode, },
+ 'extra_sql' => $extra_sql,
+ })
+ if scalar(@taxclassnums);
+
+ warn "Found taxes ".
+ join(',', map{ ref($_). " ". $_->get($_->primary_key) } @taxes). "\n"
+ if $DEBUG;
+
+ [ @taxes ];
+
+}
+
+=item collect [ HASHREF | OPTION => VALUE ... ]
+
+(Attempt to) collect money for this customer's outstanding invoices (see
+L<FS::cust_bill>). Usually used after the bill method.
+
+Actions are now triggered by billing events; see L<FS::part_event> and the
+billing events web interface. Old-style invoice events (see
+L<FS::part_bill_event>) have been deprecated.
+
+If there is an error, returns the error, otherwise returns false.
+
+Options are passed as name-value pairs.
+
+Currently available options are:
+
+=over 4
+
+=item invoice_time
+
+Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=item retry
+
+Retry card/echeck/LEC transactions even when not scheduled by invoice events.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item quiet
+
+set true to surpress email card/ACH decline notices.
+
+=item debug
+
+Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+
+=back
+
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
+=cut
+
+sub collect {
+ my( $self, %options ) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my $invoice_time = $options{'invoice_time'} || time;
+
+ #put below somehow?
+ 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;
+
+ $self->select_for_update; #mutex
+
+ if ( $DEBUG ) {
+ my $balance = $self->balance;
+ warn "$me collect customer ". $self->custnum. ": balance $balance\n"
+ }
+
+ if ( exists($options{'retry_card'}) ) {
+ carp 'retry_card option passed to collect is deprecated; use retry';
+ $options{'retry'} ||= $options{'retry_card'};
+ }
+ if ( exists($options{'retry'}) && $options{'retry'} ) {
+ my $error = $self->retry_realtime;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ #never want to roll back an event just because it returned an error
+ local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+
+ $self->do_cust_event(
+ 'debug' => ( $options{'debug'} || 0 ),
+ 'time' => $invoice_time,
+ 'check_freq' => $options{'check_freq'},
+ 'stage' => 'collect',
+ );
+
+}
+
+=item retry_realtime
+
+Schedules realtime / batch credit card / electronic check / LEC billing
+events for for retry. Useful if card information has changed or manual
+retry is desired. The 'collect' method must be called to actually retry
+the transaction.
+
+Implementation details: For either this customer, or for each of this
+customer's open invoices, changes the status of the first "done" (with
+statustext error) realtime processing event to "failed".
+
+=cut
+
+sub retry_realtime {
+ 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;
+
+ #a little false laziness w/due_cust_event (not too bad, really)
+
+ my $join = FS::part_event_condition->join_conditions_sql;
+ my $order = FS::part_event_condition->order_conditions_sql;
+ my $mine =
+ '( '
+ . join ( ' OR ' , map {
+ "( part_event.eventtable = " . dbh->quote($_)
+ . " AND tablenum IN( SELECT " . dbdef->table($_)->primary_key . " from $_ where custnum = " . dbh->quote( $self->custnum ) . "))" ;
+ } FS::part_event->eventtables)
+ . ') ';
+
+ #here is the agent virtualization
+ my $agent_virt = " ( part_event.agentnum IS NULL
+ OR part_event.agentnum = ". $self->agentnum. ' )';
+
+ #XXX this shouldn't be hardcoded, actions should declare it...
+ my @realtime_events = qw(
+ cust_bill_realtime_card
+ cust_bill_realtime_check
+ cust_bill_realtime_lec
+ cust_bill_batch
+ );
+
+ my $is_realtime_event = ' ( '. join(' OR ', map "part_event.action = '$_'",
+ @realtime_events
+ ).
+ ' ) ';
+
+ my @cust_event = qsearchs({
+ 'table' => 'cust_event',
+ 'select' => 'cust_event.*',
+ 'addl_from' => "LEFT JOIN part_event USING ( eventpart ) $join",
+ 'hashref' => { 'status' => 'done' },
+ 'extra_sql' => " AND statustext IS NOT NULL AND statustext != '' ".
+ " AND $mine AND $is_realtime_event AND $agent_virt $order" # LIMIT 1"
+ });
+
+ my %seen_invnum = ();
+ foreach my $cust_event (@cust_event) {
+
+ #max one for the customer, one for each open invoice
+ my $cust_X = $cust_event->cust_X;
+ next if $seen_invnum{ $cust_event->part_event->eventtable eq 'cust_bill'
+ ? $cust_X->invnum
+ : 0
+ }++
+ or $cust_event->part_event->eventtable eq 'cust_bill'
+ && ! $cust_X->owed;
+
+ my $error = $cust_event->retry;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error scheduling event for retry: $error";
+ }
+
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ '';
+
+}
+
+=item do_cust_event [ HASHREF | OPTION => VALUE ... ]
+
+Runs billing events; see L<FS::part_event> and the billing events web
+interface.
+
+If there is an error, returns the error, otherwise returns false.
+
+Options are passed as name-value pairs.
+
+Currently available options are:
+
+=over 4
+
+=item time
+
+Use this time when deciding when to print invoices and late notices on those invoices. The default is now. It is specified as a UNIX timestamp; see L<perlfunc/"time">). Also see L<Time::Local> and L<Date::Parse> for conversion functions.
+
+=item check_freq
+
+"1d" for the traditional, daily events (the default), or "1m" for the new monthly events (part_event.check_freq)
+
+=item stage
+
+"collect" (the default) or "pre-bill"
+
+=item quiet
+
+set true to surpress email card/ACH decline notices.
+
+=item debug
+
+Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+
+=back
+=cut
+
+# =item payby
+#
+# allows for one time override of normal customer billing method
+
+# =item retry
+#
+# Retry card/echeck/LEC transactions even when not scheduled by invoice events.
+
+sub do_cust_event {
+ my( $self, %options ) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my $time = $options{'time'} || time;
+
+ #put below somehow?
+ 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;
+
+ $self->select_for_update; #mutex
+
+ if ( $DEBUG ) {
+ my $balance = $self->balance;
+ warn "$me do_cust_event customer ". $self->custnum. ": balance $balance\n"
+ }
+
+# if ( exists($options{'retry_card'}) ) {
+# carp 'retry_card option passed to collect is deprecated; use retry';
+# $options{'retry'} ||= $options{'retry_card'};
+# }
+# if ( exists($options{'retry'}) && $options{'retry'} ) {
+# my $error = $self->retry_realtime;
+# if ( $error ) {
+# $dbh->rollback if $oldAutoCommit;
+# return $error;
+# }
+# }
+
+ # false laziness w/pay_batch::import_results
+
+ my $due_cust_event = $self->due_cust_event(
+ 'debug' => ( $options{'debug'} || 0 ),
+ 'time' => $time,
+ 'check_freq' => $options{'check_freq'},
+ 'stage' => ( $options{'stage'} || 'collect' ),
+ );
+ unless( ref($due_cust_event) ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $due_cust_event;
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ #never want to roll back an event just because it or a different one
+ # returned an error
+ local $FS::UID::AutoCommit = 1; #$oldAutoCommit;
+
+ foreach my $cust_event ( @$due_cust_event ) {
+
+ #XXX lock event
+
+ #re-eval event conditions (a previous event could have changed things)
+ unless ( $cust_event->test_conditions( 'time' => $time ) ) {
+ #don't leave stray "new/locked" records around
+ my $error = $cust_event->delete;
+ return $error if $error;
+ next;
+ }
+
+ {
+ local $FS::cust_main::Billing_Realtime::realtime_bop_decline_quiet = 1
+ if $options{'quiet'};
+ warn " running cust_event ". $cust_event->eventnum. "\n"
+ if $DEBUG > 1;
+
+ #if ( my $error = $cust_event->do_event(%options) ) { #XXX %options?
+ if ( my $error = $cust_event->do_event() ) {
+ #XXX wtf is this? figure out a proper dealio with return value
+ #from do_event
+ return $error;
+ }
+ }
+
+ }
+
+ '';
+
+}
+
+=item due_cust_event [ HASHREF | OPTION => VALUE ... ]
+
+Inserts database records for and returns an ordered listref of new events due
+for this customer, as FS::cust_event objects (see L<FS::cust_event>). If no
+events are due, an empty listref is returned. If there is an error, returns a
+scalar error message.
+
+To actually run the events, call each event's test_condition method, and if
+still true, call the event's do_event method.
+
+Options are passed as a hashref or as a list of name-value pairs. Available
+options are:
+
+=over 4
+
+=item check_freq
+
+Search only for events of this check frequency (how often events of this type are checked); currently "1d" (daily, the default) and "1m" (monthly) are recognized.
+
+=item stage
+
+"collect" (the default) or "pre-bill"
+
+=item time
+
+"Current time" for the events.
+
+=item debug
+
+Debugging level. Default is 0 (no debugging), or can be set to 1 (passed-in options), 2 (traces progress), 3 (more information), or 4 (include full search queries)
+
+=item eventtable
+
+Only return events for the specified eventtable (by default, events of all eventtables are returned)
+
+=item objects
+
+Explicitly pass the objects to be tested (typically used with eventtable).
+
+=item testonly
+
+Set to true to return the objects, but not actually insert them into the
+database.
+
+=item discount_terms
+
+Returns a list of lengths for term discounts
+
+=cut
+
+sub _discount_pkgs_and_bill {
+my $self = shift;
+
+ my @cust_bill = $self->cust_bill;
+ my $cust_bill = pop @cust_bill;
+ return () unless $cust_bill && $cust_bill->owed;
+
+ my @where = ();
+ push @where, "cust_bill_pkg.invnum = ". $cust_bill->invnum;
+ push @where, "cust_bill_pkg.pkgpart_override IS NULL";
+ push @where, "part_pkg.freq = '1'";
+ push @where, "(cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0)";
+ push @where, "(cust_pkg.susp IS NULL OR cust_pkg.susp = 0)";
+ push @where, "0<(SELECT count(*) FROM part_pkg_discount
+ WHERE part_pkg.pkgpart = part_pkg_discount.pkgpart)";
+ push @where,
+ "0=(SELECT count(*) FROM cust_bill_pkg_discount
+ WHERE cust_bill_pkg.billpkgnum = cust_bill_pkg_discount.billpkgnum)";
+
+ my $extra_sql = 'WHERE '. join(' AND ', @where);
+
+ my @cust_pkg =
+ qsearch({
+ 'table' => 'cust_pkg',
+ 'select' => "DISTINCT cust_pkg.*",
+ 'addl_from' => 'JOIN cust_bill_pkg USING(pkgnum) '.
+ 'JOIN part_pkg USING(pkgpart)',
+ 'hashref' => {},
+ 'extra_sql' => $extra_sql,
+ });
+
+ ($cust_bill, @cust_pkg);
+}
+
+sub _discountable_pkgs_at_term {
+ my ($term, @pkgs) = @_;
+ my $part_pkg = new FS::part_pkg { freq => $term - 1 };
+ grep { ( !$_->adjourn || $_->adjourn > $part_pkg->add_freq($_->bill) ) &&
+ ( !$_->expire || $_->expire > $part_pkg->add_freq($_->bill) )
+ }
+ @pkgs;
+}
+
+=item discount_terms
+
+Returns a list of lengths for term discounts
+
+=cut
+
+sub discount_terms {
+my $self = shift;
+
+ my %terms = ();
+
+ my @discount_pkgs = $self->_discount_pkgs_and_bill;
+ shift @discount_pkgs; #discard bill;
+
+ map { $terms{$_->months} = 1 }
+ grep { $_->months && $_->months > 1 }
+ map { $_->discount }
+ map { $_->part_pkg->part_pkg_discount }
+ @discount_pkgs;
+
+ return sort { $a <=> $b } keys %terms;
+
+}
+
+=back
+
+=item discount_term_values MONTHS
+
+Returns a list with credit, dollar amount saved, and total bill acheived
+by prepaying the most recent invoice for MONTHS.
+
+=cut
+
+sub discount_term_values {
+ my $self = shift;
+ my $term = shift;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ warn "$me discount_term_values called with $term\n" if $DEBUG;
+
+ my %result = ();
+
+ my @packages = $self->_discount_pkgs_and_bill;
+ my $cust_bill = shift(@packages);
+ @packages = _discountable_pkgs_at_term( $term, @packages );
+ return () unless scalar(@packages);
+
+ $_->bill($_->last_bill) foreach @packages;
+ my @final = map { new FS::cust_pkg { $_->hash } } @packages;
+
+ my %options = (
+ 'recurring_only' => 1,
+ 'no_usage_reset' => 1,
+ 'no_commit' => 1,
+ );
+
+ my %params = (
+ 'return_bill' => [],
+ 'pkg_list' => \@packages,
+ 'time' => $cust_bill->_date,
+ );
+
+ my $error = $self->bill(%options, %params);
+ die $error if $error; # XXX think about this a bit more
+
+ my $credit = 0;
+ $credit += $_->charged foreach @{$params{return_bill}};
+ $credit = sprintf('%.2f', $credit);
+ warn "$me discount_term_values $term credit: $credit\n" if $DEBUG;
+
+ %params = (
+ 'return_bill' => [],
+ 'pkg_list' => \@packages,
+ 'time' => $packages[0]->part_pkg->add_freq($cust_bill->_date)
+ );
+
+ $error = $self->bill(%options, %params);
+ die $error if $error; # XXX think about this a bit more
+
+ my $next = 0;
+ $next += $_->charged foreach @{$params{return_bill}};
+ warn "$me discount_term_values $term next: $next\n" if $DEBUG;
+
+ %params = (
+ 'return_bill' => [],
+ 'pkg_list' => \@final,
+ 'time' => $cust_bill->_date,
+ 'freq_override' => $term,
+ );
+
+ $error = $self->bill(%options, %params);
+ die $error if $error; # XXX think about this a bit more
+
+ my $final = $self->balance - $credit;
+ $final += $_->charged foreach @{$params{return_bill}};
+ $final = sprintf('%.2f', $final);
+ warn "$me discount_term_values $term final: $final\n" if $DEBUG;
+
+ my $savings = sprintf('%.2f', $self->balance + ($term - 1) * $next - $final);
+
+ ( $credit, $savings, $final );
+
+}
+
+sub discount_terms_hash {
+ my $self = shift;
+
+ my %result = ();
+ my @terms = $self->discount_terms;
+ foreach my $term (@terms) {
+ my @result = $self->discount_term_values($term);
+ $result{$term} = [ @result ] if scalar(@result);
+ }
+
+ return %result;
+
+}
+
+=back
+
+=cut
+
+sub due_cust_event {
+ my $self = shift;
+ my %opt = ref($_[0]) ? %{ $_[0] } : @_;
+
+ #???
+ #my $DEBUG = $opt{'debug'}
+ local($DEBUG) = $opt{'debug'}
+ if defined($opt{'debug'}) && $opt{'debug'} > $DEBUG;
+ $DEBUG = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ warn "$me due_cust_event called with options ".
+ join(', ', map { "$_: $opt{$_}" } keys %opt). "\n"
+ if $DEBUG;
+
+ $opt{'time'} ||= time;
+
+ 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;
+
+ $self->select_for_update #mutex
+ unless $opt{testonly};
+
+ ###
+ # find possible events (initial search)
+ ###
+
+ my @cust_event = ();
+
+ my @eventtable = $opt{'eventtable'}
+ ? ( $opt{'eventtable'} )
+ : FS::part_event->eventtables_runorder;
+
+ my $check_freq = $opt{'check_freq'} || '1d';
+
+ foreach my $eventtable ( @eventtable ) {
+
+ my @objects;
+ if ( $opt{'objects'} ) {
+
+ @objects = @{ $opt{'objects'} };
+
+ } else {
+
+ #my @objects = $self->$eventtable(); # sub cust_main { @{ [ $self ] }; }
+ if ( $eventtable eq 'cust_main' ) {
+ @objects = ( $self );
+ } else {
+
+ my $cm_join =
+ "LEFT JOIN cust_main USING ( custnum )";
+
+ #some false laziness w/Cron::bill bill_where
+
+ my $join = FS::part_event_condition->join_conditions_sql( $eventtable);
+ my $where = FS::part_event_condition->where_conditions_sql($eventtable,
+ 'time'=>$opt{'time'},
+ );
+ $where = $where ? "AND $where" : '';
+
+ my $are_part_event =
+ "EXISTS ( SELECT 1 FROM part_event $join
+ WHERE check_freq = '$check_freq'
+ AND eventtable = '$eventtable'
+ AND ( disabled = '' OR disabled IS NULL )
+ $where
+ )
+ ";
+ #eofalse
+
+ @objects = $self->$eventtable(
+ 'addl_from' => $cm_join,
+ 'extra_sql' => " AND $are_part_event",
+ );
+ }
+
+ }
+
+ my @e_cust_event = ();
+
+ my $cross = "CROSS JOIN $eventtable";
+ $cross .= ' LEFT JOIN cust_main USING ( custnum )'
+ unless $eventtable eq 'cust_main';
+
+ foreach my $object ( @objects ) {
+
+ #this first search uses the condition_sql magic for optimization.
+ #the more possible events we can eliminate in this step the better
+
+ my $cross_where = '';
+ my $pkey = $object->primary_key;
+ $cross_where = "$eventtable.$pkey = ". $object->$pkey();
+
+ my $join = FS::part_event_condition->join_conditions_sql( $eventtable );
+ my $extra_sql =
+ FS::part_event_condition->where_conditions_sql( $eventtable,
+ 'time'=>$opt{'time'}
+ );
+ my $order = FS::part_event_condition->order_conditions_sql( $eventtable );
+
+ $extra_sql = "AND $extra_sql" if $extra_sql;
+
+ #here is the agent virtualization
+ $extra_sql .= " AND ( part_event.agentnum IS NULL
+ OR part_event.agentnum = ". $self->agentnum. ' )';
+
+ $extra_sql .= " $order";
+
+ warn "searching for events for $eventtable ". $object->$pkey. "\n"
+ if $opt{'debug'} > 2;
+ my @part_event = qsearch( {
+ 'debug' => ( $opt{'debug'} > 3 ? 1 : 0 ),
+ 'select' => 'part_event.*',
+ 'table' => 'part_event',
+ 'addl_from' => "$cross $join",
+ 'hashref' => { 'check_freq' => $check_freq,
+ 'eventtable' => $eventtable,
+ 'disabled' => '',
+ },
+ 'extra_sql' => "AND $cross_where $extra_sql",
+ } );
+
+ if ( $DEBUG > 2 ) {
+ my $pkey = $object->primary_key;
+ warn " ". scalar(@part_event).
+ " possible events found for $eventtable ". $object->$pkey(). "\n";
+ }
+
+ push @e_cust_event, map { $_->new_cust_event($object) } @part_event;
+
+ }
+
+ warn " ". scalar(@e_cust_event).
+ " subtotal possible cust events found for $eventtable\n"
+ if $DEBUG > 1;
+
+ push @cust_event, @e_cust_event;
+
+ }
+
+ warn " ". scalar(@cust_event).
+ " total possible cust events found in initial search\n"
+ if $DEBUG; # > 1;
+
+
+ ##
+ # test stage
+ ##
+
+ $opt{stage} ||= 'collect';
+ @cust_event =
+ grep { my $stage = $_->part_event->event_stage;
+ $opt{stage} eq $stage or ( ! $stage && $opt{stage} eq 'collect' )
+ }
+ @cust_event;
+
+ ##
+ # test conditions
+ ##
+
+ my %unsat = ();
+
+ @cust_event = grep $_->test_conditions( 'time' => $opt{'time'},
+ 'stats_hashref' => \%unsat ),
+ @cust_event;
+
+ warn " ". scalar(@cust_event). " cust events left satisfying conditions\n"
+ if $DEBUG; # > 1;
+
+ warn " invalid conditions not eliminated with condition_sql:\n".
+ join('', map " $_: ".$unsat{$_}."\n", keys %unsat )
+ if keys %unsat && $DEBUG; # > 1;
+
+ ##
+ # insert
+ ##
+
+ unless( $opt{testonly} ) {
+ foreach my $cust_event ( @cust_event ) {
+
+ my $error = $cust_event->insert();
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ ##
+ # return
+ ##
+
+ warn " returning events: ". Dumper(@cust_event). "\n"
+ if $DEBUG > 2;
+
+ \@cust_event;
+
+}
+
+=item apply_payments_and_credits [ OPTION => VALUE ... ]
+
+Applies unapplied payments and credits.
+
+In most cases, this new method should be used in place of sequential
+apply_payments and apply_credits methods.
+
+A hash of optional arguments may be passed. Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
+If there is an error, returns the error, otherwise returns false.
+
+=cut
+
+sub apply_payments_and_credits {
+ my( $self, %options ) = @_;
+
+ 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;
+
+ $self->select_for_update; #mutex
+
+ foreach my $cust_bill ( $self->open_cust_bill ) {
+ my $error = $cust_bill->apply_payments_and_credits(%options);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "Error applying: $error";
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ ''; #no error
+
+}
+
+=item apply_credits OPTION => VALUE ...
+
+Applies (see L<FS::cust_credit_bill>) unapplied credits (see L<FS::cust_credit>)
+to outstanding invoice balances in chronological order (or reverse
+chronological order if the I<order> option is set to B<newest>) and returns the
+value of any remaining unapplied credits available for refund (see
+L<FS::cust_refund>).
+
+Dies if there is an error.
+
+=cut
+
+sub apply_credits {
+ my $self = shift;
+ my %opt = @_;
+
+ 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;
+
+ $self->select_for_update; #mutex
+
+ unless ( $self->total_unapplied_credits ) {
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ return 0;
+ }
+
+ my @credits = sort { $b->_date <=> $a->_date} (grep { $_->credited > 0 }
+ qsearch('cust_credit', { 'custnum' => $self->custnum } ) );
+
+ my @invoices = $self->open_cust_bill;
+ @invoices = sort { $b->_date <=> $a->_date } @invoices
+ if defined($opt{'order'}) && $opt{'order'} eq 'newest';
+
+ if ( $conf->exists('pkg-balances') ) {
+ # limit @credits to those w/ a pkgnum grepped from $self
+ my %pkgnums = ();
+ foreach my $i (@invoices) {
+ foreach my $li ( $i->cust_bill_pkg ) {
+ $pkgnums{$li->pkgnum} = 1;
+ }
+ }
+ @credits = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @credits;
+ }
+
+ my $credit;
+
+ foreach my $cust_bill ( @invoices ) {
+
+ if ( !defined($credit) || $credit->credited == 0) {
+ $credit = pop @credits or last;
+ }
+
+ my $owed;
+ if ( $conf->exists('pkg-balances') && $credit->pkgnum ) {
+ $owed = $cust_bill->owed_pkgnum($credit->pkgnum);
+ } else {
+ $owed = $cust_bill->owed;
+ }
+ unless ( $owed > 0 ) {
+ push @credits, $credit;
+ next;
+ }
+
+ my $amount = min( $credit->credited, $owed );
+
+ my $cust_credit_bill = new FS::cust_credit_bill ( {
+ 'crednum' => $credit->crednum,
+ 'invnum' => $cust_bill->invnum,
+ 'amount' => $amount,
+ } );
+ $cust_credit_bill->pkgnum( $credit->pkgnum )
+ if $conf->exists('pkg-balances') && $credit->pkgnum;
+ my $error = $cust_credit_bill->insert;
+ if ( $error ) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }
+
+ redo if ($cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
+
+ }
+
+ my $total_unapplied_credits = $self->total_unapplied_credits;
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ return $total_unapplied_credits;
+}
+
+=item apply_payments [ OPTION => VALUE ... ]
+
+Applies (see L<FS::cust_bill_pay>) unapplied payments (see L<FS::cust_pay>)
+to outstanding invoice balances in chronological order.
+
+ #and returns the value of any remaining unapplied payments.
+
+A hash of optional arguments may be passed. Currently "manual" is supported.
+If true, a payment receipt is sent instead of a statement when
+'payment_receipt_email' configuration option is set.
+
+Dies if there is an error.
+
+=cut
+
+sub apply_payments {
+ my( $self, %options ) = @_;
+
+ 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;
+
+ $self->select_for_update; #mutex
+
+ #return 0 unless
+
+ my @payments = sort { $b->_date <=> $a->_date }
+ grep { $_->unapplied > 0 }
+ $self->cust_pay;
+
+ my @invoices = sort { $a->_date <=> $b->_date}
+ grep { $_->owed > 0 }
+ $self->cust_bill;
+
+ if ( $conf->exists('pkg-balances') ) {
+ # limit @payments to those w/ a pkgnum grepped from $self
+ my %pkgnums = ();
+ foreach my $i (@invoices) {
+ foreach my $li ( $i->cust_bill_pkg ) {
+ $pkgnums{$li->pkgnum} = 1;
+ }
+ }
+ @payments = grep { ! $_->pkgnum || $pkgnums{$_->pkgnum} } @payments;
+ }
+
+ my $payment;
+
+ foreach my $cust_bill ( @invoices ) {
+
+ if ( !defined($payment) || $payment->unapplied == 0 ) {
+ $payment = pop @payments or last;
+ }
+
+ my $owed;
+ if ( $conf->exists('pkg-balances') && $payment->pkgnum ) {
+ $owed = $cust_bill->owed_pkgnum($payment->pkgnum);
+ } else {
+ $owed = $cust_bill->owed;
+ }
+ unless ( $owed > 0 ) {
+ push @payments, $payment;
+ next;
+ }
+
+ my $amount = min( $payment->unapplied, $owed );
+
+ my $cust_bill_pay = new FS::cust_bill_pay ( {
+ 'paynum' => $payment->paynum,
+ 'invnum' => $cust_bill->invnum,
+ 'amount' => $amount,
+ } );
+ $cust_bill_pay->pkgnum( $payment->pkgnum )
+ if $conf->exists('pkg-balances') && $payment->pkgnum;
+ my $error = $cust_bill_pay->insert(%options);
+ if ( $error ) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }
+
+ redo if ( $cust_bill->owed > 0) && ! $conf->exists('pkg-balances');
+
+ }
+
+ my $total_unapplied_payments = $self->total_unapplied_payments;
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ return $total_unapplied_payments;
+}
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_main>, L<FS::cust_main::Billing_Realtime>
+
+=cut
+
+1;
diff --git a/FS/FS/cust_main/Billing_Realtime.pm b/FS/FS/cust_main/Billing_Realtime.pm
new file mode 100644
index 0000000..2124075
--- /dev/null
+++ b/FS/FS/cust_main/Billing_Realtime.pm
@@ -0,0 +1,1459 @@
+package FS::cust_main::Billing_Realtime;
+
+use strict;
+use vars qw( $conf $DEBUG $me );
+use vars qw( $realtime_bop_decline_quiet ); #ugh
+use Data::Dumper;
+use Digest::MD5 qw(md5_base64);
+use Business::CreditCard 0.28;
+use FS::UID qw( dbh );
+use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( send_email );
+use FS::payby;
+use FS::cust_pay;
+use FS::cust_pay_pending;
+use FS::cust_refund;
+
+$realtime_bop_decline_quiet = 0;
+
+# 1 is mostly method/subroutine entry and options
+# 2 traces progress of some operations
+# 3 is even more information including possibly sensitive data
+$DEBUG = 0;
+$me = '[FS::cust_main::Billing_Realtime]';
+
+install_callback FS::UID sub {
+ $conf = new FS::Conf;
+ #yes, need it for stuff below (prolly should be cached)
+};
+
+=head1 NAME
+
+FS::cust_main::Billing_Realtime - Realtime billing mixin for cust_main
+
+=head1 SYNOPSIS
+
+=head1 DESCRIPTION
+
+These methods are available on FS::cust_main objects.
+
+=head1 METHODS
+
+=over 4
+
+=item realtime_collect [ OPTION => VALUE ... ]
+
+Runs a realtime credit card, ACH (electronic check) or phone bill transaction
+via a Business::OnlinePayment or Business::OnlineThirdPartyPayment realtime
+gateway. See L<http://420.am/business-onlinepayment> and
+L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
+
+On failure returns an error message.
+
+Returns false or a hashref upon success. The hashref contains keys popup_url reference, and collectitems. The first is a URL to which a browser should be redirected for completion of collection. The second is a reference id for the transaction suitable for the end user. The collectitems is a reference to a list of name value pairs suitable for assigning to a html form and posted to popup_url.
+
+Available options are: I<method>, I<amount>, I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>, I<pkgnum>
+
+I<method> is one of: I<CC>, I<ECHECK> and I<LEC>. If none is specified
+then it is deduced from the customer record.
+
+If no I<amount> is specified, then the customer balance is used.
+
+The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
+I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
+if set, will override the value from the customer record.
+
+I<description> is a free-text field passed to the gateway. It defaults to
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
+
+If an I<invnum> is specified, this payment (if successful) is applied to the
+specified invoice. If you don't specify an I<invnum> you might want to
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
+
+I<quiet> can be set true to surpress email decline notices.
+
+I<paynum_ref> can be set to a scalar reference. It will be filled in with the
+resulting paynum, if any.
+
+I<payunique> is a unique identifier for this payment.
+
+I<session_id> is a session identifier associated with this payment.
+
+I<depend_jobnum> allows payment capture to unlock export jobs
+
+=cut
+
+sub realtime_collect {
+ my( $self, %options ) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ if ( $DEBUG ) {
+ warn "$me realtime_collect:\n";
+ warn " $_ => $options{$_}\n" foreach keys %options;
+ }
+
+ $options{amount} = $self->balance unless exists( $options{amount} );
+ $options{method} = FS::payby->payby2bop($self->payby)
+ unless exists( $options{method} );
+
+ return $self->realtime_bop({%options});
+
+}
+
+=item realtime_bop { [ ARG => VALUE ... ] }
+
+Runs a realtime credit card, ACH (electronic check) or phone bill transaction
+via a Business::OnlinePayment realtime gateway. See
+L<http://420.am/business-onlinepayment> for supported gateways.
+
+Required arguments in the hashref are I<method>, and I<amount>
+
+Available methods are: I<CC>, I<ECHECK> and I<LEC>
+
+Available optional arguments are: I<description>, I<invnum>, I<apply>, I<quiet>, I<paynum_ref>, I<payunique>, I<session_id>
+
+The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
+I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
+if set, will override the value from the customer record.
+
+I<description> is a free-text field passed to the gateway. It defaults to
+the value defined by the business-onlinepayment-description configuration
+option, or "Internet services" if that is unset.
+
+If an I<invnum> is specified, this payment (if successful) is applied to the
+specified invoice. If you don't specify an I<invnum> you might want to
+call the B<apply_payments> method or set the I<apply> option.
+
+I<apply> can be set to true to apply a resulting payment.
+
+I<quiet> can be set true to surpress email decline notices.
+
+I<paynum_ref> can be set to a scalar reference. It will be filled in with the
+resulting paynum, if any.
+
+I<payunique> is a unique identifier for this payment.
+
+I<session_id> is a session identifier associated with this payment.
+
+I<depend_jobnum> allows payment capture to unlock export jobs
+
+I<discount_term> attempts to take a discount by prepaying for discount_term
+
+(moved from cust_bill) (probably should get realtime_{card,ach,lec} here too)
+
+=cut
+
+# some helper routines
+sub _bop_recurring_billing {
+ my( $self, %opt ) = @_;
+
+ my $method = scalar($conf->config('credit_card-recurring_billing_flag'));
+
+ if ( defined($method) && $method eq 'transaction_is_recur' ) {
+
+ return 1 if $opt{'trans_is_recur'};
+
+ } else {
+
+ my %hash = ( 'custnum' => $self->custnum,
+ 'payby' => 'CARD',
+ );
+
+ return 1
+ if qsearch('cust_pay', { %hash, 'payinfo' => $opt{'payinfo'} } )
+ || qsearch('cust_pay', { %hash, 'paymask' => $self->mask_payinfo('CARD',
+ $opt{'payinfo'} )
+ } );
+
+ }
+
+ return 0;
+
+}
+
+sub _payment_gateway {
+ my ($self, $options) = @_;
+
+ $options->{payment_gateway} = $self->agent->payment_gateway( %$options )
+ unless exists($options->{payment_gateway});
+
+ $options->{payment_gateway};
+}
+
+sub _bop_auth {
+ my ($self, $options) = @_;
+
+ (
+ 'login' => $options->{payment_gateway}->gateway_username,
+ 'password' => $options->{payment_gateway}->gateway_password,
+ );
+}
+
+sub _bop_options {
+ my ($self, $options) = @_;
+
+ $options->{payment_gateway}->gatewaynum
+ ? $options->{payment_gateway}->options
+ : @{ $options->{payment_gateway}->get('options') };
+
+}
+
+sub _bop_defaults {
+ my ($self, $options) = @_;
+
+ unless ( $options->{'description'} ) {
+ if ( $conf->exists('business-onlinepayment-description') ) {
+ my $dtempl = $conf->config('business-onlinepayment-description');
+
+ my $agent = $self->agent->agent;
+ #$pkgs... not here
+ $options->{'description'} = eval qq("$dtempl");
+ } else {
+ $options->{'description'} = 'Internet services';
+ }
+ }
+
+ $options->{payinfo} = $self->payinfo unless exists( $options->{payinfo} );
+ $options->{invnum} ||= '';
+ $options->{payname} = $self->payname unless exists( $options->{payname} );
+}
+
+sub _bop_content {
+ my ($self, $options) = @_;
+ my %content = ();
+
+ my $payip = exists($options->{'payip'}) ? $options->{'payip'} : $self->payip;
+ $content{customer_ip} = $payip if length($payip);
+
+ $content{invoice_number} = $options->{'invnum'}
+ if exists($options->{'invnum'}) && length($options->{'invnum'});
+
+ $content{email_customer} =
+ ( $conf->exists('business-onlinepayment-email_customer')
+ || $conf->exists('business-onlinepayment-email-override') );
+
+ my ($payname, $payfirst, $paylast);
+ if ( $options->{payname} && $options->{method} ne 'ECHECK' ) {
+ ($payname = $options->{payname}) =~
+ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
+ or return "Illegal payname $payname";
+ ($payfirst, $paylast) = ($1, $2);
+ } else {
+ $payfirst = $self->getfield('first');
+ $paylast = $self->getfield('last');
+ $payname = "$payfirst $paylast";
+ }
+
+ $content{last_name} = $paylast;
+ $content{first_name} = $payfirst;
+
+ $content{name} = $payname;
+
+ $content{address} = exists($options->{'address1'})
+ ? $options->{'address1'}
+ : $self->address1;
+ my $address2 = exists($options->{'address2'})
+ ? $options->{'address2'}
+ : $self->address2;
+ $content{address} .= ", ". $address2 if length($address2);
+
+ $content{city} = exists($options->{city})
+ ? $options->{city}
+ : $self->city;
+ $content{state} = exists($options->{state})
+ ? $options->{state}
+ : $self->state;
+ $content{zip} = exists($options->{zip})
+ ? $options->{'zip'}
+ : $self->zip;
+ $content{country} = exists($options->{country})
+ ? $options->{country}
+ : $self->country;
+
+ $content{referer} = 'http://cleanwhisker.420.am/'; #XXX fix referer :/
+ $content{phone} = $self->daytime || $self->night;
+
+ \%content;
+}
+
+my %bop_method2payby = (
+ 'CC' => 'CARD',
+ 'ECHECK' => 'CHEK',
+ 'LEC' => 'LECB',
+);
+
+sub realtime_bop {
+ my $self = shift;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my %options = ();
+ if (ref($_[0]) eq 'HASH') {
+ %options = %{$_[0]};
+ } else {
+ my ( $method, $amount ) = ( shift, shift );
+ %options = @_;
+ $options{method} = $method;
+ $options{amount} = $amount;
+ }
+
+ if ( $DEBUG ) {
+ warn "$me realtime_bop (new): $options{method} $options{amount}\n";
+ warn " $_ => $options{$_}\n" foreach keys %options;
+ }
+
+ return $self->fake_bop(%options) if $options{'fake'};
+
+ $self->_bop_defaults(\%options);
+
+ ###
+ # set trans_is_recur based on invnum if there is one
+ ###
+
+ my $trans_is_recur = 0;
+ if ( $options{'invnum'} ) {
+
+ my $cust_bill = qsearchs('cust_bill', { 'invnum' => $options{'invnum'} } );
+ die "invnum ". $options{'invnum'}. " not found" unless $cust_bill;
+
+ my @part_pkg =
+ map { $_->part_pkg }
+ grep { $_ }
+ map { $_->cust_pkg }
+ $cust_bill->cust_bill_pkg;
+
+ $trans_is_recur = 1
+ if grep { $_->freq ne '0' } @part_pkg;
+
+ }
+
+ ###
+ # select a gateway
+ ###
+
+ my $payment_gateway = $self->_payment_gateway( \%options );
+ my $namespace = $payment_gateway->gateway_namespace;
+
+ eval "use $namespace";
+ die $@ if $@;
+
+ ###
+ # check for banned credit card/ACH
+ ###
+
+ my $ban = qsearchs('banned_pay', {
+ 'payby' => $bop_method2payby{$options{method}},
+ 'payinfo' => md5_base64($options{payinfo}),
+ } );
+ return "Banned credit card" if $ban;
+
+ ###
+ # massage data
+ ###
+
+ my $bop_content = $self->_bop_content(\%options);
+ return $bop_content unless ref($bop_content);
+
+ my @invoicing_list = $self->invoicing_list_emailonly;
+ if ( $conf->exists('emailinvoiceautoalways')
+ || $conf->exists('emailinvoiceauto') && ! @invoicing_list
+ || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
+ push @invoicing_list, $self->all_emails;
+ }
+
+ my $email = ($conf->exists('business-onlinepayment-email-override'))
+ ? $conf->config('business-onlinepayment-email-override')
+ : $invoicing_list[0];
+
+ my $paydate = '';
+ my %content = ();
+ if ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'CC' ) {
+
+ $content{card_number} = $options{payinfo};
+ $paydate = exists($options{'paydate'})
+ ? $options{'paydate'}
+ : $self->paydate;
+ $paydate =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+ $content{expiration} = "$2/$1";
+
+ my $paycvv = exists($options{'paycvv'})
+ ? $options{'paycvv'}
+ : $self->paycvv;
+ $content{cvv2} = $paycvv
+ if length($paycvv);
+
+ my $paystart_month = exists($options{'paystart_month'})
+ ? $options{'paystart_month'}
+ : $self->paystart_month;
+
+ my $paystart_year = exists($options{'paystart_year'})
+ ? $options{'paystart_year'}
+ : $self->paystart_year;
+
+ $content{card_start} = "$paystart_month/$paystart_year"
+ if $paystart_month && $paystart_year;
+
+ my $payissue = exists($options{'payissue'})
+ ? $options{'payissue'}
+ : $self->payissue;
+ $content{issue_number} = $payissue if $payissue;
+
+ if ( $self->_bop_recurring_billing( 'payinfo' => $options{'payinfo'},
+ 'trans_is_recur' => $trans_is_recur,
+ )
+ )
+ {
+ $content{recurring_billing} = 'YES';
+ $content{acct_code} = 'rebill'
+ if $conf->exists('credit_card-recurring_billing_acct_code');
+ }
+
+ } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'ECHECK' ){
+ ( $content{account_number}, $content{routing_code} ) =
+ split('@', $options{payinfo});
+ $content{bank_name} = $options{payname};
+ $content{bank_state} = exists($options{'paystate'})
+ ? $options{'paystate'}
+ : $self->getfield('paystate');
+ $content{account_type} = exists($options{'paytype'})
+ ? uc($options{'paytype'}) || 'CHECKING'
+ : uc($self->getfield('paytype')) || 'CHECKING';
+ $content{account_name} = $self->getfield('first'). ' '.
+ $self->getfield('last');
+
+ $content{customer_org} = $self->company ? 'B' : 'I';
+ $content{state_id} = exists($options{'stateid'})
+ ? $options{'stateid'}
+ : $self->getfield('stateid');
+ $content{state_id_state} = exists($options{'stateid_state'})
+ ? $options{'stateid_state'}
+ : $self->getfield('stateid_state');
+ $content{customer_ssn} = exists($options{'ss'})
+ ? $options{'ss'}
+ : $self->ss;
+ } elsif ( $namespace eq 'Business::OnlinePayment' && $options{method} eq 'LEC' ) {
+ $content{phone} = $options{payinfo};
+ } elsif ( $namespace eq 'Business::OnlineThirdPartyPayment' ) {
+ #move along
+ } else {
+ #die an evil death
+ }
+
+ ###
+ # run transaction(s)
+ ###
+
+ my $balance = exists( $options{'balance'} )
+ ? $options{'balance'}
+ : $self->balance;
+
+ $self->select_for_update; #mutex ... just until we get our pending record in
+
+ #the checks here are intended to catch concurrent payments
+ #double-form-submission prevention is taken care of in cust_pay_pending::check
+
+ #check the balance
+ return "The customer's balance has changed; $options{method} transaction aborted."
+ if $self->balance < $balance;
+ #&& $self->balance < $options{amount}; #might as well anyway?
+
+ #also check and make sure there aren't *other* pending payments for this cust
+
+ my @pending = qsearch('cust_pay_pending', {
+ 'custnum' => $self->custnum,
+ 'status' => { op=>'!=', value=>'done' }
+ });
+ return "A payment is already being processed for this customer (".
+ join(', ', map 'paypendingnum '. $_->paypendingnum, @pending ).
+ "); $options{method} transaction aborted."
+ if scalar(@pending);
+
+ #okay, good to go, if we're a duplicate, cust_pay_pending will kick us out
+
+ my $cust_pay_pending = new FS::cust_pay_pending {
+ 'custnum' => $self->custnum,
+ #'invnum' => $options{'invnum'},
+ 'paid' => $options{amount},
+ '_date' => '',
+ 'payby' => $bop_method2payby{$options{method}},
+ 'payinfo' => $options{payinfo},
+ 'paydate' => $paydate,
+ 'recurring_billing' => $content{recurring_billing},
+ 'pkgnum' => $options{'pkgnum'},
+ 'status' => 'new',
+ 'gatewaynum' => $payment_gateway->gatewaynum || '',
+ 'session_id' => $options{session_id} || '',
+ 'jobnum' => $options{depend_jobnum} || '',
+ };
+ $cust_pay_pending->payunique( $options{payunique} )
+ if defined($options{payunique}) && length($options{payunique});
+ my $cpp_new_err = $cust_pay_pending->insert; #mutex lost when this is inserted
+ return $cpp_new_err if $cpp_new_err;
+
+ my( $action1, $action2 ) =
+ split( /\s*\,\s*/, $payment_gateway->gateway_action );
+
+ my $transaction = new $namespace( $payment_gateway->gateway_module,
+ $self->_bop_options(\%options),
+ );
+
+ $transaction->content(
+ 'type' => $options{method},
+ $self->_bop_auth(\%options),
+ 'action' => $action1,
+ 'description' => $options{'description'},
+ 'amount' => $options{amount},
+ #'invoice_number' => $options{'invnum'},
+ 'customer_id' => $self->custnum,
+ %$bop_content,
+ 'reference' => $cust_pay_pending->paypendingnum, #for now
+ 'email' => $email,
+ %content, #after
+ );
+
+ $cust_pay_pending->status('pending');
+ my $cpp_pending_err = $cust_pay_pending->replace;
+ return $cpp_pending_err if $cpp_pending_err;
+
+ #config?
+ my $BOP_TESTING = 0;
+ my $BOP_TESTING_SUCCESS = 1;
+
+ unless ( $BOP_TESTING ) {
+ $transaction->test_transaction(1)
+ if $conf->exists('business-onlinepayment-test_transaction');
+ $transaction->submit();
+ } else {
+ if ( $BOP_TESTING_SUCCESS ) {
+ $transaction->is_success(1);
+ $transaction->authorization('fake auth');
+ } else {
+ $transaction->is_success(0);
+ $transaction->error_message('fake failure');
+ }
+ }
+
+ if ( $transaction->is_success() && $namespace eq 'Business::OnlineThirdPartyPayment' ) {
+
+ return { reference => $cust_pay_pending->paypendingnum,
+ map { $_ => $transaction->$_ } qw ( popup_url collectitems ) };
+
+ } elsif ( $transaction->is_success() && $action2 ) {
+
+ $cust_pay_pending->status('authorized');
+ my $cpp_authorized_err = $cust_pay_pending->replace;
+ return $cpp_authorized_err if $cpp_authorized_err;
+
+ my $auth = $transaction->authorization;
+ my $ordernum = $transaction->can('order_number')
+ ? $transaction->order_number
+ : '';
+
+ my $capture =
+ new Business::OnlinePayment( $payment_gateway->gateway_module,
+ $self->_bop_options(\%options),
+ );
+
+ my %capture = (
+ %content,
+ type => $options{method},
+ action => $action2,
+ $self->_bop_auth(\%options),
+ order_number => $ordernum,
+ amount => $options{amount},
+ authorization => $auth,
+ description => $options{'description'},
+ );
+
+ foreach my $field (qw( authorization_source_code returned_ACI
+ transaction_identifier validation_code
+ transaction_sequence_num local_transaction_date
+ local_transaction_time AVS_result_code )) {
+ $capture{$field} = $transaction->$field() if $transaction->can($field);
+ }
+
+ $capture->content( %capture );
+
+ $capture->test_transaction(1)
+ if $conf->exists('business-onlinepayment-test_transaction');
+ $capture->submit();
+
+ unless ( $capture->is_success ) {
+ my $e = "Authorization successful but capture failed, custnum #".
+ $self->custnum. ': '. $capture->result_code.
+ ": ". $capture->error_message;
+ warn $e;
+ return $e;
+ }
+
+ }
+
+ ###
+ # remove paycvv after initial transaction
+ ###
+
+ #false laziness w/misc/process/payment.cgi - check both to make sure working
+ # correctly
+ if ( length($self->paycvv)
+ && ! grep { $_ eq cardtype($options{payinfo}) } $conf->config('cvv-save')
+ ) {
+ my $error = $self->remove_cvv;
+ if ( $error ) {
+ warn "WARNING: error removing cvv: $error\n";
+ }
+ }
+
+ ###
+ # Tokenize
+ ###
+
+
+ if ( $transaction->can('card_token') && $transaction->card_token ) {
+
+ $self->card_token($transaction->card_token);
+
+ if ( $options{'payinfo'} eq $self->payinfo ) {
+ $self->payinfo($transaction->card_token);
+ my $error = $self->replace;
+ if ( $error ) {
+ warn "WARNING: error storing token: $error, but proceeding anyway\n";
+ }
+ }
+
+ }
+
+ ###
+ # result handling
+ ###
+
+ $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
+
+}
+
+=item fake_bop
+
+=cut
+
+sub fake_bop {
+ my $self = shift;
+
+ my %options = ();
+ if (ref($_[0]) eq 'HASH') {
+ %options = %{$_[0]};
+ } else {
+ my ( $method, $amount ) = ( shift, shift );
+ %options = @_;
+ $options{method} = $method;
+ $options{amount} = $amount;
+ }
+
+ if ( $options{'fake_failure'} ) {
+ return "Error: No error; test failure requested with fake_failure";
+ }
+
+ #my $paybatch = '';
+ #if ( $payment_gateway->gatewaynum ) { # agent override
+ # $paybatch = $payment_gateway->gatewaynum. '-';
+ #}
+ #
+ #$paybatch .= "$processor:". $transaction->authorization;
+ #
+ #$paybatch .= ':'. $transaction->order_number
+ # if $transaction->can('order_number')
+ # && length($transaction->order_number);
+
+ my $paybatch = 'FakeProcessor:54:32';
+
+ my $cust_pay = new FS::cust_pay ( {
+ 'custnum' => $self->custnum,
+ 'invnum' => $options{'invnum'},
+ 'paid' => $options{amount},
+ '_date' => '',
+ 'payby' => $bop_method2payby{$options{method}},
+ #'payinfo' => $payinfo,
+ 'payinfo' => '4111111111111111',
+ 'paybatch' => $paybatch,
+ #'paydate' => $paydate,
+ 'paydate' => '2012-05-01',
+ } );
+ $cust_pay->payunique( $options{payunique} ) if length($options{payunique});
+
+ my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+
+ if ( $error ) {
+ $cust_pay->invnum(''); #try again with no specific invnum
+ my $error2 = $cust_pay->insert( $options{'manual'} ?
+ ( 'manual' => 1 ) : ()
+ );
+ if ( $error2 ) {
+ # gah, even with transactions.
+ my $e = 'WARNING: Card/ACH debited but database not updated - '.
+ "error inserting (fake!) payment: $error2".
+ " (previously tried insert with invnum #$options{'invnum'}" .
+ ": $error )";
+ warn $e;
+ return $e;
+ }
+ }
+
+ if ( $options{'paynum_ref'} ) {
+ ${ $options{'paynum_ref'} } = $cust_pay->paynum;
+ }
+
+ return ''; #no error
+
+}
+
+
+# item _realtime_bop_result CUST_PAY_PENDING, BOP_OBJECT [ OPTION => VALUE ... ]
+#
+# Wraps up processing of a realtime credit card, ACH (electronic check) or
+# phone bill transaction.
+
+sub _realtime_bop_result {
+ my( $self, $cust_pay_pending, $transaction, %options ) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ if ( $DEBUG ) {
+ warn "$me _realtime_bop_result: pending transaction ".
+ $cust_pay_pending->paypendingnum. "\n";
+ warn " $_ => $options{$_}\n" foreach keys %options;
+ }
+
+ my $payment_gateway = $options{payment_gateway}
+ or return "no payment gateway in arguments to _realtime_bop_result";
+
+ $cust_pay_pending->status($transaction->is_success() ? 'captured' : 'declined');
+ my $cpp_captured_err = $cust_pay_pending->replace;
+ return $cpp_captured_err if $cpp_captured_err;
+
+ if ( $transaction->is_success() ) {
+
+ my $paybatch = '';
+ if ( $payment_gateway->gatewaynum ) { # agent override
+ $paybatch = $payment_gateway->gatewaynum. '-';
+ }
+
+ $paybatch .= $payment_gateway->gateway_module. ":".
+ $transaction->authorization;
+
+ $paybatch .= ':'. $transaction->order_number
+ if $transaction->can('order_number')
+ && length($transaction->order_number);
+
+ my $cust_pay = new FS::cust_pay ( {
+ 'custnum' => $self->custnum,
+ 'invnum' => $options{'invnum'},
+ 'paid' => $cust_pay_pending->paid,
+ '_date' => '',
+ 'payby' => $cust_pay_pending->payby,
+ 'payinfo' => $options{'payinfo'},
+ 'paybatch' => $paybatch,
+ 'paydate' => $cust_pay_pending->paydate,
+ 'pkgnum' => $cust_pay_pending->pkgnum,
+ 'discount_term' => $options{'discount_term'},
+ } );
+ #doesn't hurt to know, even though the dup check is in cust_pay_pending now
+ $cust_pay->payunique( $options{payunique} )
+ if defined($options{payunique}) && length($options{payunique});
+
+ my $oldAutoCommit = $FS::UID::AutoCommit;
+ local $FS::UID::AutoCommit = 0;
+ my $dbh = dbh;
+
+ #start a transaction, insert the cust_pay and set cust_pay_pending.status to done in a single transction
+
+ my $error = $cust_pay->insert($options{'manual'} ? ( 'manual' => 1 ) : () );
+
+ if ( $error ) {
+ $cust_pay->invnum(''); #try again with no specific invnum
+ my $error2 = $cust_pay->insert( $options{'manual'} ?
+ ( 'manual' => 1 ) : ()
+ );
+ if ( $error2 ) {
+ # gah. but at least we have a record of the state we had to abort in
+ # from cust_pay_pending now.
+ my $e = "WARNING: $options{method} captured but payment not recorded -".
+ " error inserting payment (". $payment_gateway->gateway_module.
+ "): $error2".
+ " (previously tried insert with invnum #$options{'invnum'}" .
+ ": $error ) - pending payment saved as paypendingnum ".
+ $cust_pay_pending->paypendingnum. "\n";
+ warn $e;
+ return $e;
+ }
+ }
+
+ my $jobnum = $cust_pay_pending->jobnum;
+ if ( $jobnum ) {
+ my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
+
+ unless ( $placeholder ) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ my $e = "WARNING: $options{method} captured but job $jobnum not ".
+ "found for paypendingnum ". $cust_pay_pending->paypendingnum. "\n";
+ warn $e;
+ return $e;
+ }
+
+ $error = $placeholder->delete;
+
+ if ( $error ) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ my $e = "WARNING: $options{method} captured but could not delete ".
+ "job $jobnum for paypendingnum ".
+ $cust_pay_pending->paypendingnum. ": $error\n";
+ warn $e;
+ return $e;
+ }
+
+ }
+
+ if ( $options{'paynum_ref'} ) {
+ ${ $options{'paynum_ref'} } = $cust_pay->paynum;
+ }
+
+ $cust_pay_pending->status('done');
+ $cust_pay_pending->statustext('captured');
+ $cust_pay_pending->paynum($cust_pay->paynum);
+ my $cpp_done_err = $cust_pay_pending->replace;
+
+ if ( $cpp_done_err ) {
+
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ my $e = "WARNING: $options{method} captured but payment not recorded - ".
+ "error updating status for paypendingnum ".
+ $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
+ warn $e;
+ return $e;
+
+ } else {
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ if ( $options{'apply'} ) {
+ my $apply_error = $self->apply_payments_and_credits;
+ if ( $apply_error ) {
+ warn "WARNING: error applying payment: $apply_error\n";
+ #but we still should return no error cause the payment otherwise went
+ #through...
+ }
+ }
+
+ return ''; #no error
+
+ }
+
+ } else {
+
+ my $perror = $payment_gateway->gateway_module. " error: ".
+ $transaction->error_message;
+
+ my $jobnum = $cust_pay_pending->jobnum;
+ if ( $jobnum ) {
+ my $placeholder = qsearchs( 'queue', { 'jobnum' => $jobnum } );
+
+ if ( $placeholder ) {
+ my $error = $placeholder->depended_delete;
+ $error ||= $placeholder->delete;
+ warn "error removing provisioning jobs after declined paypendingnum ".
+ $cust_pay_pending->paypendingnum. "\n";
+ } else {
+ my $e = "error finding job $jobnum for declined paypendingnum ".
+ $cust_pay_pending->paypendingnum. "\n";
+ warn $e;
+ }
+
+ }
+
+ unless ( $transaction->error_message ) {
+
+ my $t_response;
+ if ( $transaction->can('response_page') ) {
+ $t_response = {
+ 'page' => ( $transaction->can('response_page')
+ ? $transaction->response_page
+ : ''
+ ),
+ 'code' => ( $transaction->can('response_code')
+ ? $transaction->response_code
+ : ''
+ ),
+ 'headers' => ( $transaction->can('response_headers')
+ ? $transaction->response_headers
+ : ''
+ ),
+ };
+ } else {
+ $t_response .=
+ "No additional debugging information available for ".
+ $payment_gateway->gateway_module;
+ }
+
+ $perror .= "No error_message returned from ".
+ $payment_gateway->gateway_module. " -- ".
+ ( ref($t_response) ? Dumper($t_response) : $t_response );
+
+ }
+
+ if ( !$options{'quiet'} && !$realtime_bop_decline_quiet
+ && $conf->exists('emaildecline')
+ && grep { $_ ne 'POST' } $self->invoicing_list
+ && ! grep { $transaction->error_message =~ /$_/ }
+ $conf->config('emaildecline-exclude')
+ ) {
+
+ # Send a decline alert to the customer.
+ my $msgnum = $conf->config('decline_msgnum', $self->agentnum);
+ my $error = '';
+ if ( $msgnum ) {
+ # include the raw error message in the transaction state
+ $cust_pay_pending->setfield('error', $transaction->error_message);
+ my $msg_template = qsearchs('msg_template', { msgnum => $msgnum });
+ $error = $msg_template->send( 'cust_main' => $self,
+ 'object' => $cust_pay_pending );
+ }
+ else { #!$msgnum
+
+ my @templ = $conf->config('declinetemplate');
+ my $template = new Text::Template (
+ TYPE => 'ARRAY',
+ SOURCE => [ map "$_\n", @templ ],
+ ) or return "($perror) can't create template: $Text::Template::ERROR";
+ $template->compile()
+ or return "($perror) can't compile template: $Text::Template::ERROR";
+
+ my $templ_hash = {
+ 'company_name' =>
+ scalar( $conf->config('company_name', $self->agentnum ) ),
+ 'company_address' =>
+ join("\n", $conf->config('company_address', $self->agentnum ) ),
+ 'error' => $transaction->error_message,
+ };
+
+ my $error = send_email(
+ 'from' => $conf->config('invoice_from', $self->agentnum ),
+ 'to' => [ grep { $_ ne 'POST' } $self->invoicing_list ],
+ 'subject' => 'Your payment could not be processed',
+ 'body' => [ $template->fill_in(HASH => $templ_hash) ],
+ );
+ }
+
+ $perror .= " (also received error sending decline notification: $error)"
+ if $error;
+
+ }
+
+ $cust_pay_pending->status('done');
+ $cust_pay_pending->statustext("declined: $perror");
+ my $cpp_done_err = $cust_pay_pending->replace;
+ if ( $cpp_done_err ) {
+ my $e = "WARNING: $options{method} declined but pending payment not ".
+ "resolved - error updating status for paypendingnum ".
+ $cust_pay_pending->paypendingnum. ": $cpp_done_err \n";
+ warn $e;
+ $perror = "$e ($perror)";
+ }
+
+ return $perror;
+ }
+
+}
+
+=item realtime_botpp_capture CUST_PAY_PENDING [ OPTION => VALUE ... ]
+
+Verifies successful third party processing of a realtime credit card,
+ACH (electronic check) or phone bill transaction via a
+Business::OnlineThirdPartyPayment realtime gateway. See
+L<http://420.am/business-onlinethirdpartypayment> for supported gateways.
+
+Available options are: I<description>, I<invnum>, I<quiet>, I<paynum_ref>, I<payunique>
+
+The additional options I<payname>, I<city>, I<state>,
+I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
+if set, will override the value from the customer record.
+
+I<description> is a free-text field passed to the gateway. It defaults to
+"Internet services".
+
+If an I<invnum> is specified, this payment (if successful) is applied to the
+specified invoice. If you don't specify an I<invnum> you might want to
+call the B<apply_payments> method.
+
+I<quiet> can be set true to surpress email decline notices.
+
+I<paynum_ref> can be set to a scalar reference. It will be filled in with the
+resulting paynum, if any.
+
+I<payunique> is a unique identifier for this payment.
+
+Returns a hashref containing elements bill_error (which will be undefined
+upon success) and session_id of any associated session.
+
+=cut
+
+sub realtime_botpp_capture {
+ my( $self, $cust_pay_pending, %options ) = @_;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ if ( $DEBUG ) {
+ warn "$me realtime_botpp_capture: pending transaction $cust_pay_pending\n";
+ warn " $_ => $options{$_}\n" foreach keys %options;
+ }
+
+ eval "use Business::OnlineThirdPartyPayment";
+ die $@ if $@;
+
+ ###
+ # select the gateway
+ ###
+
+ my $method = FS::payby->payby2bop($cust_pay_pending->payby);
+
+ my $payment_gateway = $cust_pay_pending->gatewaynum
+ ? qsearchs( 'payment_gateway',
+ { gatewaynum => $cust_pay_pending->gatewaynum }
+ )
+ : $self->agent->payment_gateway( 'method' => $method,
+ # 'invnum' => $cust_pay_pending->invnum,
+ # 'payinfo' => $cust_pay_pending->payinfo,
+ );
+
+ $options{payment_gateway} = $payment_gateway; # for the helper subs
+
+ ###
+ # massage data
+ ###
+
+ my @invoicing_list = $self->invoicing_list_emailonly;
+ if ( $conf->exists('emailinvoiceautoalways')
+ || $conf->exists('emailinvoiceauto') && ! @invoicing_list
+ || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
+ push @invoicing_list, $self->all_emails;
+ }
+
+ my $email = ($conf->exists('business-onlinepayment-email-override'))
+ ? $conf->config('business-onlinepayment-email-override')
+ : $invoicing_list[0];
+
+ my %content = ();
+
+ $content{email_customer} =
+ ( $conf->exists('business-onlinepayment-email_customer')
+ || $conf->exists('business-onlinepayment-email-override') );
+
+ ###
+ # run transaction(s)
+ ###
+
+ my $transaction =
+ new Business::OnlineThirdPartyPayment( $payment_gateway->gateway_module,
+ $self->_bop_options(\%options),
+ );
+
+ $transaction->reference({ %options });
+
+ $transaction->content(
+ 'type' => $method,
+ $self->_bop_auth(\%options),
+ 'action' => 'Post Authorization',
+ 'description' => $options{'description'},
+ 'amount' => $cust_pay_pending->paid,
+ #'invoice_number' => $options{'invnum'},
+ 'customer_id' => $self->custnum,
+ 'referer' => 'http://cleanwhisker.420.am/',
+ 'reference' => $cust_pay_pending->paypendingnum,
+ 'email' => $email,
+ 'phone' => $self->daytime || $self->night,
+ %content, #after
+ # plus whatever is required for bogus capture avoidance
+ );
+
+ $transaction->submit();
+
+ my $error =
+ $self->_realtime_bop_result( $cust_pay_pending, $transaction, %options );
+
+ {
+ bill_error => $error,
+ session_id => $cust_pay_pending->session_id,
+ }
+
+}
+
+=item default_payment_gateway
+
+DEPRECATED -- use agent->payment_gateway
+
+=cut
+
+sub default_payment_gateway {
+ my( $self, $method ) = @_;
+
+ die "Real-time processing not enabled\n"
+ unless $conf->exists('business-onlinepayment');
+
+ #warn "default_payment_gateway deprecated -- use agent->payment_gateway\n";
+
+ #load up config
+ my $bop_config = 'business-onlinepayment';
+ $bop_config .= '-ach'
+ if $method =~ /^(ECHECK|CHEK)$/ && $conf->exists($bop_config. '-ach');
+ my ( $processor, $login, $password, $action, @bop_options ) =
+ $conf->config($bop_config);
+ $action ||= 'normal authorization';
+ pop @bop_options if scalar(@bop_options) % 2 && $bop_options[-1] =~ /^\s*$/;
+ die "No real-time processor is enabled - ".
+ "did you set the business-onlinepayment configuration value?\n"
+ unless $processor;
+
+ ( $processor, $login, $password, $action, @bop_options )
+}
+
+=item realtime_refund_bop METHOD [ OPTION => VALUE ... ]
+
+Refunds a realtime credit card, ACH (electronic check) or phone bill transaction
+via a Business::OnlinePayment realtime gateway. See
+L<http://420.am/business-onlinepayment> for supported gateways.
+
+Available methods are: I<CC>, I<ECHECK> and I<LEC>
+
+Available options are: I<amount>, I<reason>, I<paynum>, I<paydate>
+
+Most gateways require a reference to an original payment transaction to refund,
+so you probably need to specify a I<paynum>.
+
+I<amount> defaults to the original amount of the payment if not specified.
+
+I<reason> specifies a reason for the refund.
+
+I<paydate> specifies the expiration date for a credit card overriding the
+value from the customer record or the payment record. Specified as yyyy-mm-dd
+
+Implementation note: If I<amount> is unspecified or equal to the amount of the
+orignal payment, first an attempt is made to "void" the transaction via
+the gateway (to cancel a not-yet settled transaction) and then if that fails,
+the normal attempt is made to "refund" ("credit") the transaction via the
+gateway is attempted.
+
+#The additional options I<payname>, I<address1>, I<address2>, I<city>, I<state>,
+#I<zip>, I<payinfo> and I<paydate> are also available. Any of these options,
+#if set, will override the value from the customer record.
+
+#If an I<invnum> is specified, this payment (if successful) is applied to the
+#specified invoice. If you don't specify an I<invnum> you might want to
+#call the B<apply_payments> method.
+
+=cut
+
+#some false laziness w/realtime_bop, not enough to make it worth merging
+#but some useful small subs should be pulled out
+sub realtime_refund_bop {
+ my $self = shift;
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ my %options = ();
+ if (ref($_[0]) eq 'HASH') {
+ %options = %{$_[0]};
+ } else {
+ my $method = shift;
+ %options = @_;
+ $options{method} = $method;
+ }
+
+ if ( $DEBUG ) {
+ warn "$me realtime_refund_bop (new): $options{method} refund\n";
+ warn " $_ => $options{$_}\n" foreach keys %options;
+ }
+
+ ###
+ # look up the original payment and optionally a gateway for that payment
+ ###
+
+ my $cust_pay = '';
+ my $amount = $options{'amount'};
+
+ my( $processor, $login, $password, @bop_options, $namespace ) ;
+ my( $auth, $order_number ) = ( '', '', '' );
+
+ if ( $options{'paynum'} ) {
+
+ warn " paynum: $options{paynum}\n" if $DEBUG > 1;
+ $cust_pay = qsearchs('cust_pay', { paynum=>$options{'paynum'} } )
+ or return "Unknown paynum $options{'paynum'}";
+ $amount ||= $cust_pay->paid;
+
+ $cust_pay->paybatch =~ /^((\d+)\-)?(\w+):\s*([\w\-\/ ]*)(:([\w\-]+))?$/
+ or return "Can't parse paybatch for paynum $options{'paynum'}: ".
+ $cust_pay->paybatch;
+ my $gatewaynum = '';
+ ( $gatewaynum, $processor, $auth, $order_number ) = ( $2, $3, $4, $6 );
+
+ if ( $gatewaynum ) { #gateway for the payment to be refunded
+
+ my $payment_gateway =
+ qsearchs('payment_gateway', { 'gatewaynum' => $gatewaynum } );
+ die "payment gateway $gatewaynum not found"
+ unless $payment_gateway;
+
+ $processor = $payment_gateway->gateway_module;
+ $login = $payment_gateway->gateway_username;
+ $password = $payment_gateway->gateway_password;
+ $namespace = $payment_gateway->gateway_namespace;
+ @bop_options = $payment_gateway->options;
+
+ } else { #try the default gateway
+
+ my $conf_processor;
+ my $payment_gateway =
+ $self->agent->payment_gateway('method' => $options{method});
+
+ ( $conf_processor, $login, $password, $namespace ) =
+ map { my $method = "gateway_$_"; $payment_gateway->$method }
+ qw( module username password namespace );
+
+ @bop_options = $payment_gateway->gatewaynum
+ ? $payment_gateway->options
+ : @{ $payment_gateway->get('options') };
+
+ return "processor of payment $options{'paynum'} $processor does not".
+ " match default processor $conf_processor"
+ unless $processor eq $conf_processor;
+
+ }
+
+
+ } else { # didn't specify a paynum, so look for agent gateway overrides
+ # like a normal transaction
+
+ my $payment_gateway =
+ $self->agent->payment_gateway( 'method' => $options{method},
+ #'payinfo' => $payinfo,
+ );
+ my( $processor, $login, $password, $namespace ) =
+ map { my $method = "gateway_$_"; $payment_gateway->$method }
+ qw( module username password namespace );
+
+ my @bop_options = $payment_gateway->gatewaynum
+ ? $payment_gateway->options
+ : @{ $payment_gateway->get('options') };
+
+ }
+ return "neither amount nor paynum specified" unless $amount;
+
+ eval "use $namespace";
+ die $@ if $@;
+
+ my %content = (
+ 'type' => $options{method},
+ 'login' => $login,
+ 'password' => $password,
+ 'order_number' => $order_number,
+ 'amount' => $amount,
+ 'referer' => 'http://cleanwhisker.420.am/', #XXX fix referer :/
+ );
+ $content{authorization} = $auth
+ if length($auth); #echeck/ACH transactions have an order # but no auth
+ #(at least with authorize.net)
+
+ my $disable_void_after;
+ if ($conf->exists('disable_void_after')
+ && $conf->config('disable_void_after') =~ /^(\d+)$/) {
+ $disable_void_after = $1;
+ }
+
+ #first try void if applicable
+ if ( $cust_pay && $cust_pay->paid == $amount
+ && (
+ ( not defined($disable_void_after) )
+ || ( time < ($cust_pay->_date + $disable_void_after ) )
+ )
+ ) {
+ warn " attempting void\n" if $DEBUG > 1;
+ my $void = new Business::OnlinePayment( $processor, @bop_options );
+ if ( $void->can('info') ) {
+ if ( $cust_pay->payby eq 'CARD'
+ && $void->info('CC_void_requires_card') )
+ {
+ $content{'card_number'} = $cust_pay->payinfo;
+ } elsif ( $cust_pay->payby eq 'CHEK'
+ && $void->info('ECHECK_void_requires_account') )
+ {
+ ( $content{'account_number'}, $content{'routing_code'} ) =
+ split('@', $cust_pay->payinfo);
+ $content{'name'} = $self->get('first'). ' '. $self->get('last');
+ }
+ }
+ $void->content( 'action' => 'void', %content );
+ $void->test_transaction(1)
+ if $conf->exists('business-onlinepayment-test_transaction');
+ $void->submit();
+ if ( $void->is_success ) {
+ my $error = $cust_pay->void($options{'reason'});
+ if ( $error ) {
+ # gah, even with transactions.
+ my $e = 'WARNING: Card/ACH voided but database not updated - '.
+ "error voiding payment: $error";
+ warn $e;
+ return $e;
+ }
+ warn " void successful\n" if $DEBUG > 1;
+ return '';
+ }
+ }
+
+ warn " void unsuccessful, trying refund\n"
+ if $DEBUG > 1;
+
+ #massage data
+ my $address = $self->address1;
+ $address .= ", ". $self->address2 if $self->address2;
+
+ my($payname, $payfirst, $paylast);
+ if ( $self->payname && $options{method} ne 'ECHECK' ) {
+ $payname = $self->payname;
+ $payname =~ /^\s*([\w \,\.\-\']*)?\s+([\w\,\.\-\']+)\s*$/
+ or return "Illegal payname $payname";
+ ($payfirst, $paylast) = ($1, $2);
+ } else {
+ $payfirst = $self->getfield('first');
+ $paylast = $self->getfield('last');
+ $payname = "$payfirst $paylast";
+ }
+
+ my @invoicing_list = $self->invoicing_list_emailonly;
+ if ( $conf->exists('emailinvoiceautoalways')
+ || $conf->exists('emailinvoiceauto') && ! @invoicing_list
+ || ( $conf->exists('emailinvoiceonly') && ! @invoicing_list ) ) {
+ push @invoicing_list, $self->all_emails;
+ }
+
+ my $email = ($conf->exists('business-onlinepayment-email-override'))
+ ? $conf->config('business-onlinepayment-email-override')
+ : $invoicing_list[0];
+
+ my $payip = exists($options{'payip'})
+ ? $options{'payip'}
+ : $self->payip;
+ $content{customer_ip} = $payip
+ if length($payip);
+
+ my $payinfo = '';
+ if ( $options{method} eq 'CC' ) {
+
+ if ( $cust_pay ) {
+ $content{card_number} = $payinfo = $cust_pay->payinfo;
+ (exists($options{'paydate'}) ? $options{'paydate'} : $cust_pay->paydate)
+ =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/ &&
+ ($content{expiration} = "$2/$1"); # where available
+ } else {
+ $content{card_number} = $payinfo = $self->payinfo;
+ (exists($options{'paydate'}) ? $options{'paydate'} : $self->paydate)
+ =~ /^\d{2}(\d{2})[\/\-](\d+)[\/\-]\d+$/;
+ $content{expiration} = "$2/$1";
+ }
+
+ } elsif ( $options{method} eq 'ECHECK' ) {
+
+ if ( $cust_pay ) {
+ $payinfo = $cust_pay->payinfo;
+ } else {
+ $payinfo = $self->payinfo;
+ }
+ ( $content{account_number}, $content{routing_code} )= split('@', $payinfo );
+ $content{bank_name} = $self->payname;
+ $content{account_type} = 'CHECKING';
+ $content{account_name} = $payname;
+ $content{customer_org} = $self->company ? 'B' : 'I';
+ $content{customer_ssn} = $self->ss;
+ } elsif ( $options{method} eq 'LEC' ) {
+ $content{phone} = $payinfo = $self->payinfo;
+ }
+
+ #then try refund
+ my $refund = new Business::OnlinePayment( $processor, @bop_options );
+ my %sub_content = $refund->content(
+ 'action' => 'credit',
+ 'customer_id' => $self->custnum,
+ 'last_name' => $paylast,
+ 'first_name' => $payfirst,
+ 'name' => $payname,
+ 'address' => $address,
+ 'city' => $self->city,
+ 'state' => $self->state,
+ 'zip' => $self->zip,
+ 'country' => $self->country,
+ 'email' => $email,
+ 'phone' => $self->daytime || $self->night,
+ %content, #after
+ );
+ warn join('', map { " $_ => $sub_content{$_}\n" } keys %sub_content )
+ if $DEBUG > 1;
+ $refund->test_transaction(1)
+ if $conf->exists('business-onlinepayment-test_transaction');
+ $refund->submit();
+
+ return "$processor error: ". $refund->error_message
+ unless $refund->is_success();
+
+ my $paybatch = "$processor:". $refund->authorization;
+ $paybatch .= ':'. $refund->order_number
+ if $refund->can('order_number') && $refund->order_number;
+
+ while ( $cust_pay && $cust_pay->unapplied < $amount ) {
+ my @cust_bill_pay = $cust_pay->cust_bill_pay;
+ last unless @cust_bill_pay;
+ my $cust_bill_pay = pop @cust_bill_pay;
+ my $error = $cust_bill_pay->delete;
+ last if $error;
+ }
+
+ my $cust_refund = new FS::cust_refund ( {
+ 'custnum' => $self->custnum,
+ 'paynum' => $options{'paynum'},
+ 'refund' => $amount,
+ '_date' => '',
+ 'payby' => $bop_method2payby{$options{method}},
+ 'payinfo' => $payinfo,
+ 'paybatch' => $paybatch,
+ 'reason' => $options{'reason'} || 'card or ACH refund',
+ } );
+ my $error = $cust_refund->insert;
+ if ( $error ) {
+ $cust_refund->paynum(''); #try again with no specific paynum
+ my $error2 = $cust_refund->insert;
+ if ( $error2 ) {
+ # gah, even with transactions.
+ my $e = 'WARNING: Card/ACH refunded but database not updated - '.
+ "error inserting refund ($processor): $error2".
+ " (previously tried insert with paynum #$options{'paynum'}" .
+ ": $error )";
+ warn $e;
+ return $e;
+ }
+ }
+
+ ''; #no error
+
+}
+
+=back
+
+=head1 BUGS
+
+Not autoloaded.
+
+=head1 SEE ALSO
+
+L<FS::cust_main>, L<FS::cust_main::Billing>
+
+=cut
+
+1;
diff --git a/FS/FS/cust_main/Import.pm b/FS/FS/cust_main/Import.pm
index 901ff18..7f5a3f0 100644
--- a/FS/FS/cust_main/Import.pm
+++ b/FS/FS/cust_main/Import.pm
@@ -34,7 +34,8 @@ FS::cust_main::Import - Batch customer importing
file => $file, #filename
type => $type, #csv or xls
format => $format, #extended, extended-plus_company, svc_external,
- # or svc_external_svc_phone
+ #extended-plus_company_and_options
+ #extended-plus_options, or svc_external_svc_phone
agentnum => $agentnum,
refnum => $refnum,
pkgpart => $pkgpart,
@@ -144,6 +145,19 @@ sub batch_import {
svc_acct.username svc_acct._password
);
$payby = 'BILL';
+ } elsif ( $format eq 'extended-plus_options' ) {
+ @fields = qw( agent_custid refnum
+ last first 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
+ payinfo paycvv paydate
+ invoicing_list
+ cust_pkg.pkgpart
+ svc_acct.username svc_acct._password
+ customer_options
+ );
+ $payby = 'BILL';
} elsif ( $format eq 'extended-plus_company' ) {
@fields = qw( agent_custid refnum
last first company address1 address2 city state zip country
@@ -156,6 +170,19 @@ sub batch_import {
svc_acct.username svc_acct._password
);
$payby = 'BILL';
+ } elsif ( $format eq 'extended-plus_company_and_options' ) {
+ @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
+ payinfo paycvv paydate
+ invoicing_list
+ cust_pkg.pkgpart
+ svc_acct.username svc_acct._password
+ customer_options
+ );
+ $payby = 'BILL';
} elsif ( $format =~ /^svc_external/ ) {
@fields = qw( agent_custid refnum
last first company address1 address2 city state zip country
@@ -214,6 +241,10 @@ sub batch_import {
my $oldAutoCommit = $FS::UID::AutoCommit;
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
+
+ #implies ignore_expired_card
+ local($FS::cust_main::import) = 1;
+ local($FS::cust_main::import) = 1;
my $line;
my $row = 0;
@@ -314,20 +345,34 @@ sub batch_import {
}
}
- $cust_main{'payby'} = 'CARD'
- if defined $cust_main{'payinfo'}
- && length $cust_main{'payinfo'};
+ if ( defined $cust_main{'payinfo'} && length $cust_main{'payinfo'} ) {
+ $cust_main{'payby'} = 'CARD';
+ if ($cust_main{'payinfo'} =~ /\s*([AD]?)(.*)\s*$/) {
+ $cust_main{'payby'} = 'DCRD' if $1 eq 'D';
+ $cust_main{'payinfo'} = $2;
+ }
+ }
my $invoicing_list = $cust_main{'invoicing_list'}
? [ delete $cust_main{'invoicing_list'} ]
: [];
+ my $customer_options = delete $cust_main{customer_options};
+ $cust_main{tax} = 'Y' if $customer_options =~ /taxexempt/i;
+ push @$invoicing_list, 'POST' if $customer_options =~ /postalinvoice/i;
+
my $cust_main = new FS::cust_main ( \%cust_main );
use Tie::RefHash;
tie my %hash, 'Tie::RefHash'; #this part is important
if ( $cust_pkg{'pkgpart'} ) {
+
+ unless ( $cust_pkg{'pkgpart'} =~ /^\d+$/ ) {
+ $dbh->rollback if $oldAutoCommit;
+ return 'illegal pkgpart: '. $cust_pkg{'pkgpart'};
+ }
+
my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
my @svc_x = ();
diff --git a/FS/FS/cust_main/Packages.pm b/FS/FS/cust_main/Packages.pm
new file mode 100644
index 0000000..ab7bde3
--- /dev/null
+++ b/FS/FS/cust_main/Packages.pm
@@ -0,0 +1,452 @@
+package FS::cust_main::Packages;
+
+use strict;
+use vars qw( $DEBUG $me );
+use List::Util qw( min );
+use FS::UID qw( dbh );
+use FS::Record qw( qsearch );
+use FS::cust_pkg;
+use FS::cust_svc;
+
+$DEBUG = 0;
+$me = '[FS::cust_main::Packages]';
+
+=head1 NAME
+
+FS::cust_main::Packages - Packages mixin for cust_main
+
+=head1 SYNOPSIS
+
+=head1 DESRIPTION
+
+These methods are available on FS::cust_main objects;
+
+=head1 METHODS
+
+=over 4
+
+=item order_pkg HASHREF | OPTION => VALUE ...
+
+Orders a single package.
+
+Options may be passed as a list of key/value pairs or as a hash reference.
+Options are:
+
+=over 4
+
+=item cust_pkg
+
+FS::cust_pkg object
+
+=item cust_location
+
+Optional FS::cust_location object
+
+=item svcs
+
+Optional arryaref of FS::svc_* service objects.
+
+=item depend_jobnum
+
+If this option is set to a job queue jobnum (see L<FS::queue>), all provisioning
+jobs will have a dependancy on the supplied job (they will not run until the
+specific job completes). This can be used to defer provisioning until some
+action completes (such as running the customer's credit card successfully).
+
+=item ticket_subject
+
+Optional subject for a ticket created and attached to this customer
+
+=item ticket_subject
+
+Optional queue name for ticket additions
+
+=back
+
+=cut
+
+sub order_pkg {
+ my $self = shift;
+ my $opt = ref($_[0]) ? shift : { @_ };
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ warn "$me order_pkg called with options ".
+ join(', ', map { "$_: $opt->{$_}" } keys %$opt ). "\n"
+ if $DEBUG;
+
+ my $cust_pkg = $opt->{'cust_pkg'};
+ my $svcs = $opt->{'svcs'} || [];
+
+ my %svc_options = ();
+ $svc_options{'depend_jobnum'} = $opt->{'depend_jobnum'}
+ if exists($opt->{'depend_jobnum'}) && $opt->{'depend_jobnum'};
+
+ my %insert_params = map { $opt->{$_} ? ( $_ => $opt->{$_} ) : () }
+ qw( ticket_subject ticket_queue );
+
+ 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;
+
+ if ( $opt->{'cust_location'} &&
+ ( ! $cust_pkg->locationnum || $cust_pkg->locationnum == -1 ) ) {
+ my $error = $opt->{'cust_location'}->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "inserting cust_location (transaction rolled back): $error";
+ }
+ $cust_pkg->locationnum($opt->{'cust_location'}->locationnum);
+ }
+
+ $cust_pkg->custnum( $self->custnum );
+
+ my $error = $cust_pkg->insert( %insert_params );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "inserting cust_pkg (transaction rolled back): $error";
+ }
+
+ foreach my $svc_something ( @{ $opt->{'svcs'} } ) {
+ if ( $svc_something->svcnum ) {
+ my $old_cust_svc = $svc_something->cust_svc;
+ my $new_cust_svc = new FS::cust_svc { $old_cust_svc->hash };
+ $new_cust_svc->pkgnum( $cust_pkg->pkgnum);
+ $error = $new_cust_svc->replace($old_cust_svc);
+ } else {
+ $svc_something->pkgnum( $cust_pkg->pkgnum );
+ if ( $svc_something->isa('FS::svc_acct') ) {
+ foreach ( grep { $opt->{$_.'_ref'} && ${ $opt->{$_.'_ref'} } }
+ qw( seconds upbytes downbytes totalbytes ) ) {
+ $svc_something->$_( $svc_something->$_() + ${ $opt->{$_.'_ref'} } );
+ ${ $opt->{$_.'_ref'} } = 0;
+ }
+ }
+ $error = $svc_something->insert(%svc_options);
+ }
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "inserting svc_ (transaction rolled back): $error";
+ }
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ ''; #no error
+
+}
+
+#deprecated #=item order_pkgs HASHREF [ , SECONDSREF ] [ , OPTION => VALUE ... ]
+=item order_pkgs HASHREF [ , OPTION => VALUE ... ]
+
+Like the insert method on an existing record, this method orders multiple
+packages and included services atomicaly. Pass a Tie::RefHash data structure
+to this method containing FS::cust_pkg and FS::svc_I<tablename> objects.
+There should be a better explanation of this, but until then, here's an
+example:
+
+ use Tie::RefHash;
+ tie %hash, 'Tie::RefHash'; #this part is important
+ %hash = (
+ $cust_pkg => [ $svc_acct ],
+ ...
+ );
+ $cust_main->order_pkgs( \%hash, 'noexport'=>1 );
+
+Services can be new, in which case they are inserted, or existing unaudited
+services, in which case they are linked to the newly-created package.
+
+Currently available options are: I<depend_jobnum>, I<noexport>, I<seconds_ref>,
+I<upbytes_ref>, I<downbytes_ref>, and I<totalbytes_ref>.
+
+If I<depend_jobnum> is set, all provisioning jobs will have a dependancy
+on the supplied jobnum (they will not run until the specific job completes).
+This can be used to defer provisioning until some action completes (such
+as running the customer's credit card successfully).
+
+The I<noexport> option is deprecated. If I<noexport> is set true, no
+provisioning jobs (exports) are scheduled. (You can schedule them later with
+the B<reexport> method for each cust_pkg object. Using the B<reexport> method
+on the cust_main object is not recommended, as existing services will also be
+reexported.)
+
+If I<seconds_ref>, I<upbytes_ref>, I<downbytes_ref>, or I<totalbytes_ref> is
+provided, the scalars (provided by references) will be incremented by the
+values of the prepaid card.`
+
+=cut
+
+sub order_pkgs {
+ my $self = shift;
+ my $cust_pkgs = shift;
+ my $seconds_ref = ref($_[0]) ? shift : ''; #deprecated
+ my %options = @_;
+ $seconds_ref ||= $options{'seconds_ref'};
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ warn "$me order_pkgs called with options ".
+ join(', ', map { "$_: $options{$_}" } keys %options ). "\n"
+ if $DEBUG;
+
+ 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;
+
+ local $FS::svc_Common::noexport_hack = 1 if $options{'noexport'};
+
+ foreach my $cust_pkg ( keys %$cust_pkgs ) {
+
+ my $error = $self->order_pkg(
+ 'cust_pkg' => $cust_pkg,
+ 'svcs' => $cust_pkgs->{$cust_pkg},
+ 'seconds_ref' => $seconds_ref,
+ map { $_ => $options{$_} } qw( upbytes_ref downbytes_ref totalbytes_ref
+ depend_jobnum
+ )
+ );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
+
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+ ''; #no error
+}
+
+=item all_pkgs [ OPTION => VALUE... | EXTRA_QSEARCH_PARAMS_HASHREF ]
+
+Returns all packages (see L<FS::cust_pkg>) for this customer.
+
+=cut
+
+sub all_pkgs {
+ my $self = shift;
+ my $extra_qsearch = ref($_[0]) ? shift : { @_ };
+
+ return $self->num_pkgs unless wantarray || keys %$extra_qsearch;
+
+ my @cust_pkg = ();
+ if ( $self->{'_pkgnum'} && ! keys %$extra_qsearch ) {
+ @cust_pkg = values %{ $self->{'_pkgnum'}->cache };
+ } else {
+ @cust_pkg = $self->_cust_pkg($extra_qsearch);
+ }
+
+ map { $_ } sort sort_packages @cust_pkg;
+}
+
+=item cust_pkg
+
+Synonym for B<all_pkgs>.
+
+=cut
+
+sub cust_pkg {
+ shift->all_pkgs(@_);
+}
+
+=item ncancelled_pkgs [ EXTRA_QSEARCH_PARAMS_HASHREF ]
+
+Returns all non-cancelled packages (see L<FS::cust_pkg>) for this customer.
+
+=cut
+
+sub ncancelled_pkgs {
+ my $self = shift;
+ my $extra_qsearch = ref($_[0]) ? shift : {};
+
+ local($DEBUG) = $FS::cust_main::DEBUG if $FS::cust_main::DEBUG > $DEBUG;
+
+ return $self->num_ncancelled_pkgs unless wantarray;
+
+ my @cust_pkg = ();
+ if ( $self->{'_pkgnum'} ) {
+
+ warn "$me ncancelled_pkgs: returning cached objects"
+ if $DEBUG > 1;
+
+ @cust_pkg = grep { ! $_->getfield('cancel') }
+ values %{ $self->{'_pkgnum'}->cache };
+
+ } else {
+
+ warn "$me ncancelled_pkgs: searching for packages with custnum ".
+ $self->custnum. "\n"
+ if $DEBUG > 1;
+
+ $extra_qsearch->{'extra_sql'} .= ' AND ( cancel IS NULL OR cancel = 0 ) ';
+
+ @cust_pkg = $self->_cust_pkg($extra_qsearch);
+
+ }
+
+ sort sort_packages @cust_pkg;
+
+}
+
+sub _cust_pkg {
+ my $self = shift;
+ my $extra_qsearch = ref($_[0]) ? shift : {};
+
+ $extra_qsearch->{'select'} ||= '*';
+ $extra_qsearch->{'select'} .=
+ ',( SELECT COUNT(*) FROM cust_svc WHERE cust_pkg.pkgnum = cust_svc.pkgnum )
+ AS _num_cust_svc';
+
+ map {
+ $_->{'_num_cust_svc'} = $_->get('_num_cust_svc');
+ $_;
+ }
+ qsearch({
+ %$extra_qsearch,
+ 'table' => 'cust_pkg',
+ 'hashref' => { 'custnum' => $self->custnum },
+ });
+
+}
+
+# This should be generalized to use config options to determine order.
+sub sort_packages {
+
+ my $locationsort = ( $a->locationnum || 0 ) <=> ( $b->locationnum || 0 );
+ return $locationsort if $locationsort;
+
+ if ( $a->get('cancel') xor $b->get('cancel') ) {
+ return -1 if $b->get('cancel');
+ return 1 if $a->get('cancel');
+ #shouldn't get here...
+ return 0;
+ } else {
+ my $a_num_cust_svc = $a->num_cust_svc;
+ my $b_num_cust_svc = $b->num_cust_svc;
+ return 0 if !$a_num_cust_svc && !$b_num_cust_svc;
+ return -1 if $a_num_cust_svc && !$b_num_cust_svc;
+ return 1 if !$a_num_cust_svc && $b_num_cust_svc;
+ my @a_cust_svc = $a->cust_svc;
+ my @b_cust_svc = $b->cust_svc;
+ return 0 if !scalar(@a_cust_svc) && !scalar(@b_cust_svc);
+ return -1 if scalar(@a_cust_svc) && !scalar(@b_cust_svc);
+ return 1 if !scalar(@a_cust_svc) && scalar(@b_cust_svc);
+ $a_cust_svc[0]->svc_x->label cmp $b_cust_svc[0]->svc_x->label;
+ }
+
+}
+
+=item suspended_pkgs
+
+Returns all suspended packages (see L<FS::cust_pkg>) for this customer.
+
+=cut
+
+sub suspended_pkgs {
+ my $self = shift;
+ grep { $_->susp } $self->ncancelled_pkgs;
+}
+
+=item unflagged_suspended_pkgs
+
+Returns all unflagged suspended packages (see L<FS::cust_pkg>) for this
+customer (thouse packages without the `manual_flag' set).
+
+=cut
+
+sub unflagged_suspended_pkgs {
+ my $self = shift;
+ return $self->suspended_pkgs
+ unless dbdef->table('cust_pkg')->column('manual_flag');
+ grep { ! $_->manual_flag } $self->suspended_pkgs;
+}
+
+=item unsuspended_pkgs
+
+Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
+this customer.
+
+=cut
+
+sub unsuspended_pkgs {
+ my $self = shift;
+ grep { ! $_->susp } $self->ncancelled_pkgs;
+}
+
+=item active_pkgs
+
+Returns all unsuspended (and uncancelled) packages (see L<FS::cust_pkg>) for
+this customer that are active (recurring).
+
+=cut
+
+sub active_pkgs {
+ my $self = shift;
+ grep { my $part_pkg = $_->part_pkg;
+ $part_pkg->freq ne '' && $part_pkg->freq ne '0';
+ }
+ $self->unsuspended_pkgs;
+}
+
+=item next_bill_date
+
+Returns the next date this customer will be billed, as a UNIX timestamp, or
+undef if no active package has a next bill date.
+
+=cut
+
+sub next_bill_date {
+ my $self = shift;
+ min( map $_->get('bill'), grep $_->get('bill'), $self->active_pkgs );
+}
+
+=item num_cancelled_pkgs
+
+Returns the number of cancelled packages (see L<FS::cust_pkg>) for this
+customer.
+
+=cut
+
+sub num_cancelled_pkgs {
+ shift->num_pkgs("cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0");
+}
+
+sub num_ncancelled_pkgs {
+ shift->num_pkgs("( cust_pkg.cancel IS NULL OR cust_pkg.cancel = 0 )");
+}
+
+sub num_pkgs {
+ my( $self ) = shift;
+ my $sql = scalar(@_) ? shift : '';
+ $sql = "AND $sql" if $sql && $sql !~ /^\s*$/ && $sql !~ /^\s*AND/i;
+ my $sth = dbh->prepare(
+ "SELECT COUNT(*) FROM cust_pkg WHERE custnum = ? $sql"
+ ) or die dbh->errstr;
+ $sth->execute($self->custnum) or die $sth->errstr;
+ $sth->fetchrow_arrayref->[0];
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::cust_main>, L<FS::cust_pkg>
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_main/Search.pm b/FS/FS/cust_main/Search.pm
new file mode 100644
index 0000000..ad24ff8
--- /dev/null
+++ b/FS/FS/cust_main/Search.pm
@@ -0,0 +1,881 @@
+package FS::cust_main::Search;
+
+use strict;
+use base qw( Exporter );
+use vars qw( @EXPORT_OK $DEBUG $me $conf @fuzzyfields );
+use String::Approx qw(amatch);
+use FS::UID qw( dbh );
+use FS::Record qw( qsearch );
+use FS::cust_main;
+use FS::cust_main_invoice;
+use FS::svc_acct;
+
+@EXPORT_OK = qw( smart_search );
+
+# 1 is mostly method/subroutine entry and options
+# 2 traces progress of some operations
+# 3 is even more information including possibly sensitive data
+$DEBUG = 0;
+$me = '[FS::cust_main::Search]';
+
+@fuzzyfields = @FS::cust_main::fuzzyfields;
+
+install_callback FS::UID sub {
+ $conf = new FS::Conf;
+ #yes, need it for stuff below (prolly should be cached)
+};
+
+=head1 NAME
+
+FS::cust_main::Search - Customer searching
+
+=head1 SYNOPSIS
+
+ use FS::cust_main::Search;
+
+ FS::cust_main::Search::smart_search(%options);
+
+ FS::cust_main::Search::email_search(%options);
+
+ FS::cust_main::Search->search( \%options );
+
+ FS::cust_main::Search->fuzzy_search( \%fuzzy_hashref );
+
+=head1 SUBROUTINES
+
+=over 4
+
+=item smart_search OPTION => VALUE ...
+
+Accepts the following options: I<search>, the string to search for. The string
+will be searched for as a customer number, phone number, name or company name,
+as an exact, or, in some cases, a substring or fuzzy match (see the source code
+for the exact heuristics used); I<no_fuzzy_on_exact>, causes smart_search to
+skip fuzzy matching when an exact match is found.
+
+Any additional options are treated as an additional qualifier on the search
+(i.e. I<agentnum>).
+
+Returns a (possibly empty) array of FS::cust_main objects.
+
+=cut
+
+sub smart_search {
+ my %options = @_;
+
+ #here is the agent virtualization
+ my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+ my @cust_main = ();
+
+ my $skip_fuzzy = delete $options{'no_fuzzy_on_exact'};
+ my $search = delete $options{'search'};
+ ( my $alphanum_search = $search ) =~ s/\W//g;
+
+ if ( $alphanum_search =~ /^1?(\d{3})(\d{3})(\d{4})(\d*)$/ ) { #phone# search
+
+ #false laziness w/Record::ut_phone
+ my $phonen = "$1-$2-$3";
+ $phonen .= " x$4" if $4;
+
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { %options },
+ 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
+ ' ( '.
+ join(' OR ', map "$_ = '$phonen'",
+ qw( daytime night fax
+ ship_daytime ship_night ship_fax )
+ ).
+ ' ) '.
+ " AND $agentnums_sql", #agent virtualization
+ } );
+
+ unless ( @cust_main || $phonen =~ /x\d+$/ ) { #no exact match
+ #try looking for matches with extensions unless one was specified
+
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { %options },
+ 'extra_sql' => ( scalar(keys %options) ? ' AND ' : ' WHERE ' ).
+ ' ( '.
+ join(' OR ', map "$_ LIKE '$phonen\%'",
+ qw( daytime night
+ ship_daytime ship_night )
+ ).
+ ' ) '.
+ " AND $agentnums_sql", #agent virtualization
+ } );
+
+ }
+
+ # custnum search (also try agent_custid), with some tweaking options if your
+ # legacy cust "numbers" have letters
+ }
+
+ if ( $search =~ /^\s*(\d+)\s*$/
+ || ( $conf->config('cust_main-agent_custid-format') eq 'ww?d+'
+ && $search =~ /^\s*(\w\w?\d+)\s*$/
+ )
+ || ( $conf->exists('address1-search' )
+ && $search =~ /^\s*(\d+\-?\w*)\s*$/ #i.e. 1234A or 9432-D
+ )
+ )
+ {
+
+ my $num = $1;
+
+ if ( $num =~ /^(\d+)$/ && $num <= 2147483647 ) { #need a bigint custnum? wow
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { 'custnum' => $num, %options },
+ 'extra_sql' => " AND $agentnums_sql", #agent virtualization
+ } );
+ }
+
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { 'agent_custid' => $num, %options },
+ 'extra_sql' => " AND $agentnums_sql", #agent virtualization
+ } );
+
+ if ( $conf->exists('address1-search') ) {
+ my $len = length($num);
+ $num = lc($num);
+ foreach my $prefix ( '', 'ship_' ) {
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { %options, },
+ 'extra_sql' =>
+ ( keys(%options) ? ' AND ' : ' WHERE ' ).
+ " LOWER(SUBSTRING(${prefix}address1 FROM 1 FOR $len)) = '$num' ".
+ " AND $agentnums_sql",
+ } );
+ }
+ }
+
+ } elsif ( $search =~ /^\s*(\S.*\S)\s+\((.+), ([^,]+)\)\s*$/ ) {
+
+ my($company, $last, $first) = ( $1, $2, $3 );
+
+ # "Company (Last, First)"
+ #this is probably something a browser remembered,
+ #so just do an exact search (but case-insensitive, so USPS standardization
+ #doesn't throw a wrench in the works)
+
+ foreach my $prefix ( '', 'ship_' ) {
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { %options },
+ 'extra_sql' =>
+ ( keys(%options) ? ' AND ' : ' WHERE ' ).
+ join(' AND ',
+ " LOWER(${prefix}first) = ". dbh->quote(lc($first)),
+ " LOWER(${prefix}last) = ". dbh->quote(lc($last)),
+ " LOWER(${prefix}company) = ". dbh->quote(lc($company)),
+ $agentnums_sql,
+ ),
+ } );
+ }
+
+ } elsif ( $search =~ /^\s*(\S.*\S)\s*$/ ) { # value search
+ # try (ship_){last,company}
+
+ my $value = lc($1);
+
+ # # remove "(Last, First)" in "Company (Last, First)", otherwise the
+ # # full strings the browser remembers won't work
+ # $value =~ s/\([\w \,\.\-\']*\)$//; #false laziness w/Record::ut_name
+
+ use Lingua::EN::NameParse;
+ my $NameParse = new Lingua::EN::NameParse(
+ auto_clean => 1,
+ allow_reversed => 1,
+ );
+
+ my($last, $first) = ( '', '' );
+ #maybe disable this too and just rely on NameParse?
+ if ( $value =~ /^(.+),\s*([^,]+)$/ ) { # Last, First
+
+ ($last, $first) = ( $1, $2 );
+
+ #} elsif ( $value =~ /^(.+)\s+(.+)$/ ) {
+ } elsif ( ! $NameParse->parse($value) ) {
+
+ my %name = $NameParse->components;
+ $first = $name{'given_name_1'} || $name{'initials_1'}; #wtf NameParse, Ed?
+ $last = $name{'surname_1'};
+
+ }
+
+ if ( $first && $last ) {
+
+ my($q_last, $q_first) = ( dbh->quote($last), dbh->quote($first) );
+
+ #exact
+ my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
+ $sql .= "
+ ( ( LOWER(last) = $q_last AND LOWER(first) = $q_first )
+ OR ( LOWER(ship_last) = $q_last AND LOWER(ship_first) = $q_first )
+ )";
+
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => \%options,
+ 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
+ } );
+
+ # or it just be something that was typed in... (try that in a sec)
+
+ }
+
+ my $q_value = dbh->quote($value);
+
+ #exact
+ my $sql = scalar(keys %options) ? ' AND ' : ' WHERE ';
+ $sql .= " ( LOWER(last) = $q_value
+ OR LOWER(company) = $q_value
+ OR LOWER(ship_last) = $q_value
+ OR LOWER(ship_company) = $q_value
+ ";
+ $sql .= " OR LOWER(address1) = $q_value
+ OR LOWER(ship_address1) = $q_value
+ "
+ if $conf->exists('address1-search');
+ $sql .= " )";
+
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => \%options,
+ 'extra_sql' => "$sql AND $agentnums_sql", #agent virtualization
+ } );
+
+ #no exact match, trying substring/fuzzy
+ #always do substring & fuzzy (unless they're explicity config'ed off)
+ #getting complaints searches are not returning enough
+ unless ( @cust_main && $skip_fuzzy || $conf->exists('disable-fuzzy') ) {
+
+ #still some false laziness w/search (was search/cust_main.cgi)
+
+ #substring
+
+ my @hashrefs = (
+ { 'company' => { op=>'ILIKE', value=>"%$value%" }, },
+ { 'ship_company' => { op=>'ILIKE', value=>"%$value%" }, },
+ );
+
+ if ( $first && $last ) {
+
+ push @hashrefs,
+ { 'first' => { op=>'ILIKE', value=>"%$first%" },
+ 'last' => { op=>'ILIKE', value=>"%$last%" },
+ },
+ { 'ship_first' => { op=>'ILIKE', value=>"%$first%" },
+ 'ship_last' => { op=>'ILIKE', value=>"%$last%" },
+ },
+ ;
+
+ } else {
+
+ push @hashrefs,
+ { 'last' => { op=>'ILIKE', value=>"%$value%" }, },
+ { 'ship_last' => { op=>'ILIKE', value=>"%$value%" }, },
+ ;
+ }
+
+ if ( $conf->exists('address1-search') ) {
+ push @hashrefs,
+ { 'address1' => { op=>'ILIKE', value=>"%$value%" }, },
+ { 'ship_address1' => { op=>'ILIKE', value=>"%$value%" }, },
+ ;
+ }
+
+ foreach my $hashref ( @hashrefs ) {
+
+ push @cust_main, qsearch( {
+ 'table' => 'cust_main',
+ 'hashref' => { %$hashref,
+ %options,
+ },
+ 'extra_sql' => " AND $agentnums_sql", #agent virtualizaiton
+ } );
+
+ }
+
+ #fuzzy
+ my @fuzopts = (
+ \%options, #hashref
+ '', #select
+ " AND $agentnums_sql", #extra_sql #agent virtualization
+ );
+
+ if ( $first && $last ) {
+ push @cust_main, FS::cust_main::Search->fuzzy_search(
+ { 'last' => $last, #fuzzy hashref
+ 'first' => $first }, #
+ @fuzopts
+ );
+ }
+ foreach my $field ( 'last', 'company' ) {
+ push @cust_main,
+ FS::cust_main::Search->fuzzy_search( { $field => $value }, @fuzopts );
+ }
+ if ( $conf->exists('address1-search') ) {
+ push @cust_main,
+ FS::cust_main::Search->fuzzy_search( { 'address1' => $value }, @fuzopts );
+ }
+
+ }
+
+ }
+
+ #eliminate duplicates
+ my %saw = ();
+ @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
+ @cust_main;
+
+}
+
+=item email_search
+
+Accepts the following options: I<email>, the email address to search for. The
+email address will be searched for as an email invoice destination and as an
+svc_acct account.
+
+#Any additional options are treated as an additional qualifier on the search
+#(i.e. I<agentnum>).
+
+Returns a (possibly empty) array of FS::cust_main objects (but usually just
+none or one).
+
+=cut
+
+sub email_search {
+ my %options = @_;
+
+ local($DEBUG) = 1;
+
+ my $email = delete $options{'email'};
+
+ #we're only being used by RT at the moment... no agent virtualization yet
+ #my $agentnums_sql = $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+ my @cust_main = ();
+
+ if ( $email =~ /([^@]+)\@([^@]+)/ ) {
+
+ my ( $user, $domain ) = ( $1, $2 );
+
+ warn "$me smart_search: searching for $user in domain $domain"
+ if $DEBUG;
+
+ push @cust_main,
+ map $_->cust_main,
+ qsearch( {
+ 'table' => 'cust_main_invoice',
+ 'hashref' => { 'dest' => $email },
+ }
+ );
+
+ push @cust_main,
+ map $_->cust_main,
+ grep $_,
+ map $_->cust_svc->cust_pkg,
+ qsearch( {
+ 'table' => 'svc_acct',
+ 'hashref' => { 'username' => $user, },
+ 'extra_sql' =>
+ 'AND ( SELECT domain FROM svc_domain
+ WHERE svc_acct.domsvc = svc_domain.svcnum
+ ) = '. dbh->quote($domain),
+ }
+ );
+ }
+
+ my %saw = ();
+ @cust_main = grep { !$saw{$_->custnum}++ } @cust_main;
+
+ warn "$me smart_search: found ". scalar(@cust_main). " unique customers"
+ if $DEBUG;
+
+ @cust_main;
+
+}
+
+=back
+
+=head1 CLASS METHODS
+
+=over 4
+
+=item search HASHREF
+
+(Class method)
+
+Returns a qsearch hash expression to search for parameters specified in
+HASHREF. Valid parameters are
+
+=over 4
+
+=item agentnum
+
+=item status
+
+=item address
+
+=item cancelled_pkgs
+
+bool
+
+=item signupdate
+
+listref of start date, end date
+
+=item payby
+
+listref
+
+=item paydate_year
+
+=item paydate_month
+
+=item current_balance
+
+listref (list returned by FS::UI::Web::parse_lt_gt($cgi, 'current_balance'))
+
+=item cust_fields
+
+=item flattened_pkgs
+
+bool
+
+=back
+
+=cut
+
+sub search {
+ my ($class, $params) = @_;
+
+ my $dbh = dbh;
+
+ my @where = ();
+ my $orderby;
+
+ ##
+ # parse agent
+ ##
+
+ if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+ push @where,
+ "cust_main.agentnum = $1";
+ }
+
+ ##
+ # do the same for user
+ ##
+
+ if ( $params->{'usernum'} =~ /^(\d+)$/ and $1 ) {
+ push @where,
+ "cust_main.usernum = $1";
+ }
+
+ ##
+ # parse status
+ ##
+
+ #prospect ordered active inactive suspended cancelled
+ if ( grep { $params->{'status'} eq $_ } FS::cust_main->statuses() ) {
+ my $method = $params->{'status'}. '_sql';
+ #push @where, $class->$method();
+ push @where, FS::cust_main->$method();
+ }
+
+ ##
+ # address
+ ##
+ if ( $params->{'address'} =~ /\S/ ) {
+ my $address = dbh->quote('%'. lc($params->{'address'}). '%');
+ push @where, '('. join(' OR ',
+ map "LOWER($_) LIKE $address",
+ qw(address1 address2 ship_address1 ship_address2)
+ ).
+ ')';
+ }
+
+ ##
+ # parse cancelled package checkbox
+ ##
+
+ my $pkgwhere = "";
+
+ $pkgwhere .= "AND (cancel = 0 or cancel is null)"
+ unless $params->{'cancelled_pkgs'};
+
+ ##
+ # parse without census tract checkbox
+ ##
+
+ push @where, "(censustract = '' or censustract is null)"
+ if $params->{'no_censustract'};
+
+ ##
+ # parse with hardcoded tax location checkbox
+ ##
+
+ push @where, "geocode is not null"
+ if $params->{'with_geocode'};
+
+ ##
+ # dates
+ ##
+
+ foreach my $field (qw( signupdate )) {
+
+ next unless exists($params->{$field});
+
+ my($beginning, $ending, $hour) = @{$params->{$field}};
+
+ push @where,
+ "cust_main.$field IS NOT NULL",
+ "cust_main.$field >= $beginning",
+ "cust_main.$field <= $ending";
+
+ if(defined $hour) {
+ if ($dbh->{Driver}->{Name} =~ /Pg/i) {
+ push @where, "extract(hour from to_timestamp(cust_main.$field)) = $hour";
+ }
+ elsif( $dbh->{Driver}->{Name} =~ /mysql/i) {
+ push @where, "hour(from_unixtime(cust_main.$field)) = $hour"
+ }
+ else {
+ warn "search by time of day not supported on ".$dbh->{Driver}->{Name}." databases";
+ }
+ }
+
+ $orderby ||= "ORDER BY cust_main.$field";
+
+ }
+
+ ###
+ # classnum
+ ###
+
+ if ( $params->{'classnum'} ) {
+
+ my @classnum = ref( $params->{'classnum'} )
+ ? @{ $params->{'classnum'} }
+ : ( $params->{'classnum'} );
+
+ @classnum = grep /^(\d*)$/, @classnum;
+
+ if ( @classnum ) {
+ push @where, '( '. join(' OR ', map {
+ $_ ? "cust_main.classnum = $_"
+ : "cust_main.classnum IS NULL"
+ }
+ @classnum
+ ).
+ ' )';
+ }
+
+ }
+
+ ###
+ # payby
+ ###
+
+ if ( $params->{'payby'} ) {
+
+ my @payby = ref( $params->{'payby'} )
+ ? @{ $params->{'payby'} }
+ : ( $params->{'payby'} );
+
+ @payby = grep /^([A-Z]{4})$/, @payby;
+
+ push @where, '( '. join(' OR ', map "cust_main.payby = '$_'", @payby). ' )'
+ if @payby;
+
+ }
+
+ ###
+ # paydate_year / paydate_month
+ ###
+
+ if ( $params->{'paydate_year'} =~ /^(\d{4})$/ ) {
+ my $year = $1;
+ $params->{'paydate_month'} =~ /^(\d\d?)$/
+ or die "paydate_year without paydate_month?";
+ my $month = $1;
+
+ push @where,
+ 'paydate IS NOT NULL',
+ "paydate != ''",
+ "CAST(paydate AS timestamp) < CAST('$year-$month-01' AS timestamp )"
+;
+ }
+
+ ###
+ # invoice terms
+ ###
+
+ if ( $params->{'invoice_terms'} =~ /^([\w ]+)$/ ) {
+ my $terms = $1;
+ if ( $1 eq 'NULL' ) {
+ push @where,
+ "( cust_main.invoice_terms IS NULL OR cust_main.invoice_terms = '' )";
+ } else {
+ push @where,
+ "cust_main.invoice_terms IS NOT NULL",
+ "cust_main.invoice_terms = '$1'";
+ }
+ }
+
+ ##
+ # amounts
+ ##
+
+ if ( $params->{'current_balance'} ) {
+
+ #my $balance_sql = $class->balance_sql();
+ my $balance_sql = FS::cust_main->balance_sql();
+
+ my @current_balance =
+ ref( $params->{'current_balance'} )
+ ? @{ $params->{'current_balance'} }
+ : ( $params->{'current_balance'} );
+
+ push @where, map { s/current_balance/$balance_sql/; $_ }
+ @current_balance;
+
+ }
+
+ ##
+ # custbatch
+ ##
+
+ if ( $params->{'custbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+ push @where,
+ "cust_main.custbatch = '$1'";
+ }
+
+ if ( $params->{'tagnum'} ) {
+ my @tagnums = ref( $params->{'tagnum'} ) ? @{ $params->{'tagnum'} } : ( $params->{'tagnum'} );
+
+ @tagnums = grep /^(\d+)$/, @tagnums;
+
+ if ( @tagnums ) {
+ my $tags_where = "0 < (select count(1) from cust_tag where "
+ . " cust_tag.custnum = cust_main.custnum and tagnum in ("
+ . join(',', @tagnums) . "))";
+
+ push @where, $tags_where;
+ }
+ }
+
+
+ ##
+ # setup queries, subs, etc. for the search
+ ##
+
+ $orderby ||= 'ORDER BY custnum';
+
+ # here is the agent virtualization
+ push @where, $FS::CurrentUser::CurrentUser->agentnums_sql;
+
+ my $extra_sql = scalar(@where) ? ' WHERE '. join(' AND ', @where) : '';
+
+ my $addl_from = 'LEFT JOIN cust_pkg USING ( custnum ) ';
+
+ my $count_query = "SELECT COUNT(*) FROM cust_main $extra_sql";
+
+ my @select = (
+ 'cust_main.custnum',
+ FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
+ );
+
+ my(@extra_headers) = ();
+ my(@extra_fields) = ();
+
+ if ($params->{'flattened_pkgs'}) {
+
+ if ($dbh->{Driver}->{Name} eq 'Pg') {
+
+ push @select, "array_to_string(array(select pkg from cust_pkg left join part_pkg using ( pkgpart ) where cust_main.custnum = cust_pkg.custnum $pkgwhere),'|') as magic";
+
+ }elsif ($dbh->{Driver}->{Name} =~ /^mysql/i) {
+ push @select, "GROUP_CONCAT(pkg SEPARATOR '|') as magic";
+ $addl_from .= " LEFT JOIN part_pkg using ( pkgpart )";
+ }else{
+ warn "warning: unknown database type ". $dbh->{Driver}->{Name}.
+ "omitting packing information from report.";
+ }
+
+ my $header_query = "SELECT COUNT(cust_pkg.custnum = cust_main.custnum) AS count FROM cust_main $addl_from $extra_sql $pkgwhere group by cust_main.custnum order by count desc limit 1";
+
+ my $sth = dbh->prepare($header_query) or die dbh->errstr;
+ $sth->execute() or die $sth->errstr;
+ my $headerrow = $sth->fetchrow_arrayref;
+ my $headercount = $headerrow ? $headerrow->[0] : 0;
+ while($headercount) {
+ unshift @extra_headers, "Package ". $headercount;
+ unshift @extra_fields, eval q!sub {my $c = shift;
+ my @a = split '\|', $c->magic;
+ my $p = $a[!.--$headercount. q!];
+ $p;
+ };!;
+ }
+
+ }
+
+ if ( $params->{'with_geocode'} ) {
+
+ unshift @extra_headers, 'Tax location override', 'Calculated tax location';
+ unshift @extra_fields, sub { my $c = shift; $c->get('geocode'); },
+ sub { my $c = shift;
+ $c->set('geocode', '');
+ $c->geocode('cch'); #XXX only cch right now
+ };
+ push @select, 'geocode';
+ push @select, 'zip' unless grep { $_ eq 'zip' } @select;
+ push @select, 'ship_zip' unless grep { $_ eq 'ship_zip' } @select;
+ }
+
+ my $select = join(', ', @select);
+
+ my $sql_query = {
+ 'table' => 'cust_main',
+ 'select' => $select,
+ 'hashref' => {},
+ 'extra_sql' => $extra_sql,
+ 'order_by' => $orderby,
+ 'count_query' => $count_query,
+ 'extra_headers' => \@extra_headers,
+ 'extra_fields' => \@extra_fields,
+ };
+
+}
+
+=item fuzzy_search FUZZY_HASHREF [ HASHREF, SELECT, EXTRA_SQL, CACHE_OBJ ]
+
+Performs a fuzzy (approximate) search and returns the matching FS::cust_main
+records. Currently, I<first>, I<last>, I<company> and/or I<address1> may be
+specified (the appropriate ship_ field is also searched).
+
+Additional options are the same as FS::Record::qsearch
+
+=cut
+
+sub fuzzy_search {
+ my( $self, $fuzzy, $hash, @opt) = @_;
+ #$self
+ $hash ||= {};
+ my @cust_main = ();
+
+ check_and_rebuild_fuzzyfiles();
+ foreach my $field ( keys %$fuzzy ) {
+
+ my $all = $self->all_X($field);
+ next unless scalar(@$all);
+
+ my %match = ();
+ $match{$_}=1 foreach ( amatch( $fuzzy->{$field}, ['i'], @$all ) );
+
+ my @fcust = ();
+ foreach ( keys %match ) {
+ push @fcust, qsearch('cust_main', { %$hash, $field=>$_}, @opt);
+ push @fcust, qsearch('cust_main', { %$hash, "ship_$field"=>$_}, @opt);
+ }
+ my %fsaw = ();
+ push @cust_main, grep { ! $fsaw{$_->custnum}++ } @fcust;
+ }
+
+ # we want the components of $fuzzy ANDed, not ORed, but still don't want dupes
+ my %saw = ();
+ @cust_main = grep { ++$saw{$_->custnum} == scalar(keys %$fuzzy) } @cust_main;
+
+ @cust_main;
+
+}
+
+=back
+
+=head1 UTILITY SUBROUTINES
+
+=over 4
+
+=item check_and_rebuild_fuzzyfiles
+
+=cut
+
+sub check_and_rebuild_fuzzyfiles {
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ rebuild_fuzzyfiles() if grep { ! -e "$dir/cust_main.$_" } @fuzzyfields
+}
+
+=item rebuild_fuzzyfiles
+
+=cut
+
+sub rebuild_fuzzyfiles {
+
+ use Fcntl qw(:flock);
+
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ mkdir $dir, 0700 unless -d $dir;
+
+ foreach my $fuzzy ( @fuzzyfields ) {
+
+ open(LOCK,">>$dir/cust_main.$fuzzy")
+ or die "can't open $dir/cust_main.$fuzzy: $!";
+ flock(LOCK,LOCK_EX)
+ or die "can't lock $dir/cust_main.$fuzzy: $!";
+
+ open (CACHE,">$dir/cust_main.$fuzzy.tmp")
+ or die "can't open $dir/cust_main.$fuzzy.tmp: $!";
+
+ foreach my $field ( $fuzzy, "ship_$fuzzy" ) {
+ my $sth = dbh->prepare("SELECT $field FROM cust_main".
+ " WHERE $field != '' AND $field IS NOT NULL");
+ $sth->execute or die $sth->errstr;
+
+ while ( my $row = $sth->fetchrow_arrayref ) {
+ print CACHE $row->[0]. "\n";
+ }
+
+ }
+
+ close CACHE or die "can't close $dir/cust_main.$fuzzy.tmp: $!";
+
+ rename "$dir/cust_main.$fuzzy.tmp", "$dir/cust_main.$fuzzy";
+ close LOCK;
+ }
+
+}
+
+=item all_X
+
+=cut
+
+sub all_X {
+ my( $self, $field ) = @_;
+ my $dir = $FS::UID::conf_dir. "/cache.". $FS::UID::datasrc;
+ open(CACHE,"<$dir/cust_main.$field")
+ or die "can't open $dir/cust_main.$field: $!";
+ my @array = map { chomp; $_; } <CACHE>;
+ close CACHE;
+ \@array;
+}
+
+=head1 BUGS
+
+Bed bugs
+
+=head1 SEE ALSO
+
+L<FS::cust_main>, L<FS::Record>
+
+=cut
+
+1;
+
diff --git a/FS/FS/cust_main/_Marketgear.pm b/FS/FS/cust_main/_Marketgear.pm
new file mode 100644
index 0000000..2d3c927
--- /dev/null
+++ b/FS/FS/cust_main/_Marketgear.pm
@@ -0,0 +1,146 @@
+package FS::cust_main::_Marketgear;
+
+use strict;
+use vars qw( $DEBUG $me $conf );
+
+$DEBUG = 0;
+$me = '[FS::cust_main::_Marketgear]';
+
+install_callback FS::UID sub {
+ $conf = new FS::Conf;
+};
+
+sub start_copy_skel {
+ my $self = shift;
+
+ return '' unless $conf->config('cust_main-skeleton_tables')
+ && $conf->config('cust_main-skeleton_custnum');
+
+ warn " inserting skeleton records\n"
+ if $DEBUG > 1 || $cust_main::DEBUG > 1;
+
+ #'mg_user_preference' => {},
+ #'mg_user_indicator_profile.user_indicator_profile_id' => { 'mg_profile_indicator.profile_indicator_id' => { 'mg_profile_details.profile_detail_id' }, },
+ #'mg_watchlist_header.watchlist_header_id' => { 'mg_watchlist_details.watchlist_details_id' },
+ #'mg_user_grid_header.grid_header_id' => { 'mg_user_grid_details.user_grid_details_id' },
+ #'mg_portfolio_header.portfolio_header_id' => { 'mg_portfolio_trades.portfolio_trades_id' => { 'mg_portfolio_trades_positions.portfolio_trades_positions_id' } },
+ my @tables = eval(join('\n',$conf->config('cust_main-skeleton_tables')));
+ die $@ if $@;
+
+ _copy_skel( 'cust_main', #tablename
+ $conf->config('cust_main-skeleton_custnum'), #sourceid
+ $self->custnum, #destid
+ @tables, #child tables
+ );
+}
+
+#recursive subroutine, not a method
+sub _copy_skel {
+ my( $table, $sourceid, $destid, %child_tables ) = @_;
+
+ my $primary_key;
+ if ( $table =~ /^(\w+)\.(\w+)$/ ) {
+ ( $table, $primary_key ) = ( $1, $2 );
+ } else {
+ my $dbdef_table = dbdef->table($table);
+ $primary_key = $dbdef_table->primary_key
+ or return "$table has no primary key".
+ " (or do you need to run dbdef-create?)";
+ }
+
+ warn " _copy_skel: $table.$primary_key $sourceid to $destid for ".
+ join (', ', keys %child_tables). "\n"
+ if $DEBUG > 2;
+
+ foreach my $child_table_def ( keys %child_tables ) {
+
+ my $child_table;
+ my $child_pkey = '';
+ if ( $child_table_def =~ /^(\w+)\.(\w+)$/ ) {
+ ( $child_table, $child_pkey ) = ( $1, $2 );
+ } else {
+ $child_table = $child_table_def;
+
+ $child_pkey = dbdef->table($child_table)->primary_key;
+ # or return "$table has no primary key".
+ # " (or do you need to run dbdef-create?)\n";
+ }
+
+ my $sequence = '';
+ if ( keys %{ $child_tables{$child_table_def} } ) {
+
+ return "$child_table has no primary key".
+ " (run dbdef-create or try specifying it?)\n"
+ unless $child_pkey;
+
+ #false laziness w/Record::insert and only works on Pg
+ #refactor the proper last-inserted-id stuff out of Record::insert if this
+ # ever gets use for anything besides a quick kludge for one customer
+ my $default = dbdef->table($child_table)->column($child_pkey)->default;
+ $default =~ /^nextval\(\(?'"?([\w\.]+)"?'/i
+ or return "can't parse $child_table.$child_pkey default value ".
+ " for sequence name: $default";
+ $sequence = $1;
+
+ }
+
+ my @sel_columns = grep { $_ ne $primary_key }
+ dbdef->table($child_table)->columns;
+ my $sel_columns = join(', ', @sel_columns );
+
+ my @ins_columns = grep { $_ ne $child_pkey } @sel_columns;
+ my $ins_columns = ' ( '. join(', ', $primary_key, @ins_columns ). ' ) ';
+ my $placeholders = ' ( ?, '. join(', ', map '?', @ins_columns ). ' ) ';
+
+ my $sel_st = "SELECT $sel_columns FROM $child_table".
+ " WHERE $primary_key = $sourceid";
+ warn " $sel_st\n"
+ if $DEBUG > 2;
+ my $sel_sth = dbh->prepare( $sel_st )
+ or return dbh->errstr;
+
+ $sel_sth->execute or return $sel_sth->errstr;
+
+ while ( my $row = $sel_sth->fetchrow_hashref ) {
+
+ warn " selected row: ".
+ join(', ', map { "$_=".$row->{$_} } keys %$row ). "\n"
+ if $DEBUG > 2;
+
+ my $statement =
+ "INSERT INTO $child_table $ins_columns VALUES $placeholders";
+ my $ins_sth =dbh->prepare($statement)
+ or return dbh->errstr;
+ my @param = ( $destid, map $row->{$_}, @ins_columns );
+ warn " $statement: [ ". join(', ', @param). " ]\n"
+ if $DEBUG > 2;
+ $ins_sth->execute( @param )
+ or return $ins_sth->errstr;
+
+ #next unless keys %{ $child_tables{$child_table} };
+ next unless $sequence;
+
+ #another section of that laziness
+ my $seq_sql = "SELECT currval('$sequence')";
+ my $seq_sth = dbh->prepare($seq_sql) or return dbh->errstr;
+ $seq_sth->execute or return $seq_sth->errstr;
+ my $insertid = $seq_sth->fetchrow_arrayref->[0];
+
+ # don't drink soap! recurse! recurse! okay!
+ my $error =
+ _copy_skel( $child_table_def,
+ $row->{$child_pkey}, #sourceid
+ $insertid, #destid
+ %{ $child_tables{$child_table_def} },
+ );
+ return $error if $error;
+
+ }
+
+ }
+
+ return '';
+
+}
+
+1;
diff --git a/FS/FS/cust_main_Mixin.pm b/FS/FS/cust_main_Mixin.pm
index 3dde95f..8c8553c 100644
--- a/FS/FS/cust_main_Mixin.pm
+++ b/FS/FS/cust_main_Mixin.pm
@@ -5,6 +5,8 @@ use vars qw( $DEBUG $me );
use Carp qw( confess );
use FS::UID qw(dbh);
use FS::cust_main;
+use FS::Record qw( qsearch qsearchs );
+use FS::Misc qw( send_email generate_email );
$DEBUG = 0;
$me = '[FS::cust_main_Mixin]';
@@ -33,6 +35,11 @@ for example, from a JOINed search. See httemplate/search/ for examples.
sub cust_unlinked_msg { '(unlinked)'; }
sub cust_linked { $_[0]->custnum; }
+sub cust_main {
+ my $self = shift;
+ $self->cust_linked ? qsearchs('cust_main', {custnum => $self->custnum}) : '';
+}
+
=item display_custnum
Given an object that contains fields from cust_main (say, from a JOINed
@@ -330,6 +337,209 @@ sub cust_search_sql {
}
+=item email_search_result HASHREF
+
+Emails a notice to the specified customers. Customers without
+invoice email destinations will be skipped.
+
+Parameters:
+
+=over 4
+
+=item job
+
+Queue job for status updates. Required.
+
+=item search
+
+Hashref of params to the L<search()> method. Required.
+
+=item msgnum
+
+Message template number (see L<FS::msg_template>). Overrides all
+of the following options.
+
+=item from
+
+From: address
+
+=item subject
+
+Email Subject:
+
+=item html_body
+
+HTML body
+
+=item text_body
+
+Text body
+
+=back
+
+Returns an error message, or false for success.
+
+If any messages fail to send, they will be queued as individual
+jobs which can be manually retried. If the first ten messages
+in the job fail, the entire job will abort and return an error.
+
+=cut
+
+use Storable qw(thaw);
+use MIME::Base64;
+use Data::Dumper qw(Dumper);
+
+sub email_search_result {
+ my($class, $param) = @_;
+
+ my $msgnum = $param->{msgnum};
+ my $from = delete $param->{from};
+ my $subject = delete $param->{subject};
+ my $html_body = delete $param->{html_body};
+ my $text_body = delete $param->{text_body};
+ my $error = '';
+
+ my $job = delete $param->{'job'}
+ or die "email_search_result must run from the job queue.\n";
+
+ my $msg_template;
+ if ( $msgnum ) {
+ $msg_template = qsearchs('msg_template', { msgnum => $msgnum } )
+ or die "msgnum $msgnum not found\n";
+ }
+
+ $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
+ unless ref($param->{'payby'});
+
+ my $sql_query = $class->search($param->{'search'});
+
+ my $count_query = delete($sql_query->{'count_query'});
+ my $count_sth = dbh->prepare($count_query)
+ or die "Error preparing $count_query: ". dbh->errstr;
+ $count_sth->execute
+ or die "Error executing $count_query: ". $count_sth->errstr;
+ my $count_arrayref = $count_sth->fetchrow_arrayref;
+ my $num_cust = $count_arrayref->[0];
+
+ my( $num, $last, $min_sec ) = (0, time, 5); #progresbar foo
+ my @retry_jobs = ();
+ my $dups = 0;
+ my $success = 0;
+ my %sent_to = ();
+
+ #eventually order+limit magic to reduce memory use?
+ foreach my $obj ( qsearch($sql_query) ) {
+
+ #progressbar first, so that the count is right
+ $num++;
+ if ( time - $min_sec > $last ) {
+ my $error = $job->update_statustext(
+ int( 100 * $num / $num_cust )
+ );
+ die $error if $error;
+ $last = time;
+ }
+
+ my $cust_main = $obj->cust_main;
+ my @message;
+ if ( !$cust_main ) {
+ next; # unlinked object; nothing else we can do
+ }
+
+ if( $sent_to{$cust_main->custnum} ) {
+ # avoid duplicates
+ $dups++;
+ next;
+ }
+
+ $sent_to{$cust_main->custnum} = 1;
+
+ if ( $msg_template ) {
+ # XXX add support for other context objects?
+ # If we do that, handling of "duplicates" will
+ # have to be smarter. Currently we limit to
+ # one message per custnum because they'd all
+ # be identical.
+ @message = $msg_template->prepare( 'cust_main' => $cust_main );
+ }
+ else {
+ my $to = $cust_main->invoicing_list_emailonly_scalar;
+ next if !$to;
+
+ @message = (
+ 'from' => $from,
+ 'to' => $to,
+ 'subject' => $subject,
+ 'html_body' => $html_body,
+ 'text_body' => $text_body,
+ );
+ } #if $msg_template
+
+ $error = send_email( generate_email( @message ) );
+
+ if($error) {
+ # queue the sending of this message so that the user can see what we
+ # tried to do, and retry if desired
+ my $queue = new FS::queue {
+ 'job' => 'FS::Misc::process_send_email',
+ 'custnum' => $cust_main->custnum,
+ 'status' => 'failed',
+ 'statustext' => $error,
+ };
+ $queue->insert(@message);
+ push @retry_jobs, $queue;
+ }
+ else {
+ $success++;
+ }
+
+ if($success == 0 and
+ (scalar(@retry_jobs) > 10 or $num == $num_cust)
+ ) {
+ # 10 is arbitrary, but if we have enough failures, that's
+ # probably a configuration or network problem, and we
+ # abort the batch and run away screaming.
+ # We NEVER do this if anything was successfully sent.
+ $_->delete foreach (@retry_jobs);
+ return "multiple failures: '$error'\n";
+ }
+ } # foreach $obj
+
+ if(@retry_jobs) {
+ # fail the job, but with a status message that makes it clear
+ # something was sent.
+ return "Sent $success, skipped $dups duplicate(s), failed ".scalar(@retry_jobs).". Failed attempts placed in job queue.\n";
+ }
+
+ return '';
+}
+
+sub process_email_search_result {
+ my $job = shift;
+ #warn "$me process_re_X $method for job $job\n" if $DEBUG;
+
+ my $param = thaw(decode_base64(shift));
+ warn Dumper($param) if $DEBUG;
+
+ $param->{'job'} = $job;
+
+ $param->{'search'} = thaw(decode_base64($param->{'search'}))
+ or die "process_email_search_result requires search params.\n";
+
+# $param->{'payby'} = [ split(/\0/, $param->{'payby'}) ]
+# unless ref($param->{'payby'});
+
+ my $table = $param->{'table'}
+ or die "process_email_search_result requires table.\n";
+
+ eval "use FS::$table;";
+ die "error loading FS::$table: $@\n" if $@;
+
+ my $error = "FS::$table"->email_search_result( $param );
+ die $error if $error;
+
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/cust_main_county.pm b/FS/FS/cust_main_county.pm
index ab1ac1e..e84fa98 100644
--- a/FS/FS/cust_main_county.pm
+++ b/FS/FS/cust_main_county.pm
@@ -256,7 +256,10 @@ sub taxline {
my ($mon,$year) =
(localtime( $cust_bill_pkg->sdate || $invoice_date ) )[4,5];
$mon++;
- my $freq = $part_pkg->freq || 1;
+ my $freq = $cust_bill_pkg->freq;
+ unless ($freq) {
+ $freq = $part_pkg->freq || 1; # less trustworthy fallback
+ }
if ( $freq !~ /(\d+)$/ ) {
$dbh->rollback if $oldAutoCommit;
return "daily/weekly package definitions not (yet?)".
diff --git a/FS/FS/cust_pay.pm b/FS/FS/cust_pay.pm
index eee263a..014affe 100644
--- a/FS/FS/cust_pay.pm
+++ b/FS/FS/cust_pay.pm
@@ -12,6 +12,7 @@ use Text::Template;
use FS::UID qw( getotaker );
use FS::Misc qw( send_email );
use FS::Record qw( dbh qsearch qsearchs );
+use FS::CurrentUser;
use FS::payby;
use FS::cust_main_Mixin;
use FS::payinfo_transaction_Mixin;
@@ -140,6 +141,10 @@ For backwards-compatibility and convenience, if the additional field invnum
is defined, an FS::cust_bill_pay record for the full amount of the payment
will be created. In this case, custnum is optional.
+If the additional field discount_term is defined then a prepayment discount
+is taken for that length of time. It is an error for the customer to owe
+after this payment is made.
+
A hash of optional arguments may be passed. Currently "manual" is supported.
If true, a payment receipt is sent instead of a statement when
'payment_receipt_email' configuration option is set.
@@ -182,6 +187,51 @@ sub insert {
return "error inserting cust_pay: $error";
}
+ if ( my $credit_type = $conf->config('prepayment_discounts-credit_type') ) {
+ if ( my $months = $self->discount_term ) {
+ #hmmm... error handling
+ my ($credit, $savings, $total) =
+ $cust_main->discount_term_values($months);
+ my $cust_credit = new FS::cust_credit {
+ 'custnum' => $self->custnum,
+ 'amount' => $credit,
+ 'reason' => 'customer chose to prepay for discount',
+ };
+ $error = $cust_credit->insert('reason_type' => $credit_type);
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error inserting cust_pay: $error";
+ }
+ my @pkgs = $cust_main->_discount_pkgs_and_bill;
+ my $cust_bill = shift(@pkgs);
+ @pkgs = &FS::cust_main::Billing::_discountable_pkgs_at_term($months, @pkgs);
+ $_->bill($_->last_bill) foreach @pkgs;
+ $error = $cust_main->bill(
+ 'recurring_only' => 1,
+ 'time' => $cust_bill->invoice_date,
+ 'no_usage_reset' => 1,
+ 'pkg_list' => \@pkgs,
+ 'freq_override' => $months,
+ );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error inserting cust_pay: $error";
+ }
+ $error = $cust_main->apply_payments_and_credits;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "error inserting cust_pay: $error";
+ }
+ my $new_balance = $cust_main->balance;
+ if ($new_balance > 0) {
+ $dbh->rollback if $oldAutoCommit;
+ return "balance after prepay discount attempt: $new_balance";
+ }
+
+ }
+
+ }
+
if ( $self->invnum ) {
my $cust_bill_pay = new FS::cust_bill_pay {
'invnum' => $self->invnum,
@@ -214,6 +264,36 @@ sub insert {
}
#eslaf
+ #bill setup fees for voip_cdr bill_every_call packages
+ #some false laziness w/search in freeside-cdrd
+ my $addl_from =
+ 'LEFT JOIN part_pkg USING ( pkgpart ) '.
+ "LEFT JOIN part_pkg_option
+ ON ( cust_pkg.pkgpart = part_pkg_option.pkgpart
+ AND part_pkg_option.optionname = 'bill_every_call' )";
+
+ my $extra_sql = " AND plan = 'voip_cdr' AND optionvalue = '1' ".
+ " AND ( cust_pkg.setup IS NULL OR cust_pkg.setup = 0 ) ";
+
+ my @cust_pkg = qsearch({
+ 'table' => 'cust_pkg',
+ 'addl_from' => $addl_from,
+ 'hashref' => { 'custnum' => $self->custnum,
+ 'susp' => '',
+ 'cancel' => '',
+ },
+ 'extra_sql' => $extra_sql,
+ });
+
+ if ( @cust_pkg ) {
+ warn "voip_cdr bill_every_call packages found; billing customer\n";
+ my $bill_error = $self->cust_main->bill_and_collect( 'fatal' => 'return' );
+ if ( $bill_error ) {
+ warn "WARNING: Error billing customer: $bill_error\n";
+ }
+ }
+ #end of billing setup fees for voip_cdr bill_every_call packages
+
$dbh->commit or die $dbh->errstr if $oldAutoCommit;
#payment receipt
@@ -351,14 +431,17 @@ sub delete {
}
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
You can, but probably shouldn't modify payments...
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record. If there is an error, returns the error,
+otherwise returns false.
+
=cut
sub replace {
- #return "Can't modify payment!"
my $self = shift;
return "Can't modify closed payment" if $self->closed =~ /^Y/i;
$self->SUPER::replace(@_);
@@ -374,7 +457,7 @@ returns the error, otherwise returns false. Called by the insert method.
sub check {
my $self = shift;
- $self->otaker(getotaker) unless ($self->otaker);
+ $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
my $error =
$self->ut_numbern('paynum')
@@ -387,6 +470,7 @@ sub check {
|| $self->ut_enum('closed', [ '', 'Y' ])
|| $self->ut_foreign_keyn('pkgnum', 'cust_pkg', 'pkgnum')
|| $self->payinfo_check()
+ || $self->ut_numbern('discount_term')
;
return $error if $error;
@@ -398,6 +482,9 @@ sub check {
$self->_date(time) unless $self->_date;
+ return "invalid discount_term"
+ if ($self->discount_term && $self->discount_term < 2);
+
#i guess not now, with cust_pay_pending, if we actually make it here, we _do_ want to record it
# # UNIQUE index should catch this too, without race conditions, but this
# # should give a better error message the other 99.9% of the time...
@@ -446,24 +533,31 @@ sub send_receipt {
my $conf = new FS::Conf;
+ return '' unless $conf->exists('payment_receipt');
+
my @invoicing_list = $cust_main->invoicing_list_emailonly;
return '' unless @invoicing_list;
$cust_bill ||= ($cust_main->cust_bill)[-1]; #rather inefficient though?
+ my $error = '';
+
if ( ( exists($opt->{'manual'}) && $opt->{'manual'} )
- || ! $conf->exists('invoice_html_statement') # XXX msg_template
+ || ! $conf->exists('invoice_html_statement')
|| ! $cust_bill
- ) {
-
- my $error = '';
+ )
+ {
- if( $conf->exists('payment_receipt_msgnum') ) {
+ if ( $conf->exists('payment_receipt_msgnum')
+ && $conf->config('payment_receipt_msgnum')
+ )
+ {
my $msg_template =
FS::msg_template->by_key($conf->config('payment_receipt_msgnum'));
$error = $msg_template->send('cust_main'=> $cust_main, 'object'=> $self);
- }
- elsif ( $conf->exists('payment_receipt_email') ) {
+
+ } elsif ( $conf->exists('payment_receipt_email') ) {
+
my $receipt_template = new Text::Template (
TYPE => 'ARRAY',
SOURCE => [ map "$_\n", $conf->config('payment_receipt_email') ],
@@ -506,22 +600,27 @@ sub send_receipt {
'body' => [ $receipt_template->fill_in( HASH => \%fill_in ) ],
);
- }
- else { # no payment_receipt_msgnum or payment_receipt_email
+ } else {
- my $queue = new FS::queue {
- 'paynum' => $self->paynum,
- 'job' => 'FS::cust_bill::queueable_email',
- };
+ warn "payment_receipt is on, but no payment_receipt_msgnum or invoice_html_statement is configured\n";
- $queue->insert(
- 'invnum' => $cust_bill->invnum,
- 'template' => 'statement',
- );
}
+
+ } else { #not manual
+
+ my $queue = new FS::queue {
+ 'paynum' => $self->paynum,
+ 'job' => 'FS::cust_bill::queueable_email',
+ };
+
+ $error = $queue->insert(
+ 'invnum' => $cust_bill->invnum,
+ 'template' => 'statement',
+ );
+
+ }
warn "send_receipt: $error\n" if $error;
- } #$opt{manual} || no invoice_html_statement || customer has no invoices
}
=item cust_bill_pay
@@ -734,9 +833,10 @@ sub _upgrade_data { #class method
my $h_cust_pay = $cust_pay->h_search('insert');
if ( $h_cust_pay ) {
next if $cust_pay->otaker eq $h_cust_pay->history_user;
- $cust_pay->otaker($h_cust_pay->history_user);
+ #$cust_pay->otaker($h_cust_pay->history_user);
+ $cust_pay->set('otaker', $h_cust_pay->history_user);
} else {
- $cust_pay->otaker('legacy');
+ $cust_pay->set('otaker', 'legacy');
}
delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
@@ -795,7 +895,9 @@ sub _upgrade_data { #class method
# otaker->usernum upgrade
###
+ delete $FS::payby::hash{'COMP'}->{cust_pay}; #quelle kludge
$class->_upgrade_otaker(%opts);
+ $FS::payby::hash{'COMP'}->{cust_pay} = ''; #restore it
}
diff --git a/FS/FS/cust_pay_void.pm b/FS/FS/cust_pay_void.pm
index e18a4f6..3a30acb 100644
--- a/FS/FS/cust_pay_void.pm
+++ b/FS/FS/cust_pay_void.pm
@@ -1,11 +1,14 @@
package FS::cust_pay_void;
use strict;
-use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::Record );
+use base qw( FS::otaker_Mixin FS::payinfo_Mixin FS::cust_main_Mixin
+ FS::Record );
use vars qw( @encrypted_fields $otaker_upgrade_kludge );
use Business::CreditCard;
use FS::UID qw(getotaker);
-use FS::Record qw(qsearchs dbh fields); # qsearch );
+use FS::Record qw(qsearch qsearchs dbh fields);
+use FS::CurrentUser;
+use FS::access_user;
use FS::cust_pay;
#use FS::cust_bill;
#use FS::cust_bill_pay;
@@ -154,16 +157,13 @@ sub unvoid {
Deletes this voided payment. You probably don't want to use this directly; see
the B<unvoid> method to add the original payment back.
-=item replace OLD_RECORD
+=item replace [ OLD_RECORD ]
-Currently unimplemented.
+You can, but probably shouldn't modify voided payments...
-=cut
-
-sub replace {
- return "Can't modify voided payments!" unless $otaker_upgrade_kludge;
- shift->SUPER::replace(@_);
-}
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record. If there is an error, returns the error,
+otherwise returns false.
=item check
@@ -221,7 +221,8 @@ sub check {
return $error if $error;
}
- $self->otaker(getotaker) unless $self->otaker;
+ $self->void_usernum($FS::CurrentUser::CurrentUser->usernum)
+ unless $self->void_usernum;
$self->SUPER::check;
}
@@ -237,11 +238,41 @@ sub cust_main {
qsearchs( 'cust_main', { 'custnum' => $self->custnum } );
}
+=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 } );
+}
+
# Used by FS::Upgrade to migrate to a new database.
sub _upgrade_data { # class method
my ($class, %opts) = @_;
+
+ my $sql = "SELECT usernum FROM access_user WHERE username = ( SELECT history_user FROM h_cust_pay_void WHERE paynum = ? AND history_action = 'insert' ORDER BY history_date LIMIT 1 ) ";
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+
+ foreach my $cust_pay_void (qsearch('cust_pay_void', {'void_usernum' => ''})) {
+ $sth->execute($cust_pay_void->paynum) or die $sth->errstr;
+ my $usernum = $sth->fetchrow_arrayref->[0] or next;
+ if ( $usernum ) {
+ $cust_pay_void->void_usernum($usernum);
+ my $error = $cust_pay_void->replace;
+ die $error if $error;
+ } else {
+ warn "cust_pay_void upgrade: can't find access_user record for ". $cust_pay_void->paynum. "\n";
+ }
+ }
+
local($otaker_upgrade_kludge) = 1;
$class->_upgrade_otaker(%opts);
+
+ #XXX look for the h_cust_pay delete records and when that's a different
+ # usernum, set usernum
}
=back
diff --git a/FS/FS/cust_pkg.pm b/FS/FS/cust_pkg.pm
index 0f9a611..ce5ee11 100644
--- a/FS/FS/cust_pkg.pm
+++ b/FS/FS/cust_pkg.pm
@@ -13,6 +13,7 @@ use MIME::Entity;
use FS::UID qw( getotaker dbh );
use FS::Misc qw( send_email );
use FS::Record qw( qsearch qsearchs );
+use FS::CurrentUser;
use FS::cust_svc;
use FS::part_pkg;
use FS::cust_main;
@@ -152,6 +153,10 @@ date
date
+=item contract_end
+
+date
+
=item cancel
date
@@ -258,17 +263,12 @@ sub insert {
$self->start_date( timelocal_nocheck(0,0,0,1,$mon,$year) );
}
- my $expire_months = $self->part_pkg->option('expire_months', 1);
- if ( $expire_months && !$self->expire ) {
- my $start = $self->start_date || $self->setup || time;
-
- #false laziness w/part_pkg::add_freq
- my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($start) )[0,1,2,3,4,5];
- $mon += $expire_months;
- until ( $mon < 12 ) { $mon -= 12; $year++; }
-
- #$self->expire( timelocal_nocheck($sec,$min,$hour,$mday,$mon,$year) );
- $self->expire( timelocal_nocheck(0,0,0,$mday,$mon,$year) );
+ foreach my $action ( qw(expire adjourn contract_end) ) {
+ my $months = $self->part_pkg->option("${action}_months",1);
+ if($months and !$self->$action) {
+ my $start = $self->start_date || $self->setup || time;
+ $self->$action( $self->part_pkg->add_freq($start, $months) );
+ }
}
local $SIG{HUP} = 'IGNORE';
@@ -563,7 +563,7 @@ sub check {
}
- $self->otaker(getotaker) unless $self->otaker;
+ $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
if ( $self->dbdef_table->column('manual_flag') ) {
$self->manual_flag('') if $self->manual_flag eq ' ';
@@ -1023,10 +1023,16 @@ sub unsuspend {
my $conf = new FS::Conf;
- $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive
- if ( $opt{'adjust_next_bill'}
- || $conf->exists('unsuspend-always_adjust_next_bill_date') )
- && $inactive > 0 && ( $hash{'bill'} || $hash{'setup'} );
+ if ( $inactive > 0 &&
+ ( $hash{'bill'} || $hash{'setup'} ) &&
+ ( $opt{'adjust_next_bill'} ||
+ $conf->exists('unsuspend-always_adjust_next_bill_date') ||
+ $self->part_pkg->option('unsuspend_adjust_bill', 1) )
+ ) {
+
+ $hash{'bill'} = ( $hash{'bill'} || $hash{'setup'} ) + $inactive;
+
+ }
$hash{'susp'} = '';
$hash{'adjourn'} = '' if $hash{'adjourn'} < time;
@@ -1112,7 +1118,7 @@ Options are:
=over 4
-=item locaitonnum
+=item locationnum
New locationnum, to change the location for this package.
@@ -1129,9 +1135,15 @@ New pkgpart (see L<FS::part_pkg>).
New refnum (see L<FS::part_referral>).
+=item keep_dates
+
+Set to true to transfer billing dates (start_date, setup, last_bill, bill,
+susp, adjourn, cancel, expire, and contract_end) to the new package.
+
=back
-At least one option must be specified (otherwise, what's the point?)
+At least one of locationnum, cust_location, pkgpart, refnum must be specified
+(otherwise, what's the point?)
Returns either the new FS::cust_pkg object or a scalar error.
@@ -1189,6 +1201,13 @@ sub change {
$opt->{'locationnum'} = $opt->{'cust_location'}->locationnum;
}
+ if ( $opt->{'keep_dates'} ) {
+ foreach my $date ( qw(setup bill last_bill susp adjourn cancel expire
+ start_date contract_end ) ) {
+ $hash{$date} = $self->getfield($date);
+ }
+ }
+
# Create the new package.
my $cust_pkg = new FS::cust_pkg {
custnum => $self->custnum,
@@ -1238,7 +1257,7 @@ sub change {
? ()
: ( 'null' => 1 )
)
- if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover');
+ if $part_pkg->can('reset_usage') && ! $part_pkg->option('usage_rollover',1);
if ($error) {
$dbh->rollback if $oldAutoCommit;
@@ -1268,6 +1287,60 @@ sub change {
}
+use Data::Dumper;
+use Storable 'thaw';
+use MIME::Base64;
+sub process_bulk_cust_pkg {
+ my $job = shift;
+ my $param = thaw(decode_base64(shift));
+ warn Dumper($param) if $DEBUG;
+
+ my $old_part_pkg = qsearchs('part_pkg',
+ { pkgpart => $param->{'old_pkgpart'} });
+ my $new_part_pkg = qsearchs('part_pkg',
+ { pkgpart => $param->{'new_pkgpart'} });
+ die "Must select a new package type\n" unless $new_part_pkg;
+ #my $keep_dates = $param->{'keep_dates'} || 0;
+ my $keep_dates = 1; # there is no good reason to turn this off
+
+ 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_pkgs = qsearch('cust_pkg', { 'pkgpart' => $param->{'old_pkgpart'} } );
+
+ my $i = 0;
+ foreach my $old_cust_pkg ( @cust_pkgs ) {
+ $i++;
+ $job->update_statustext(int(100*$i/(scalar @cust_pkgs)));
+ if ( $old_cust_pkg->getfield('cancel') ) {
+ warn '[process_bulk_cust_pkg ] skipping canceled pkgnum '.
+ $old_cust_pkg->pkgnum."\n"
+ if $DEBUG;
+ next;
+ }
+ warn '[process_bulk_cust_pkg] changing pkgnum '.$old_cust_pkg->pkgnum."\n"
+ if $DEBUG;
+ my $error = $old_cust_pkg->change(
+ 'pkgpart' => $param->{'new_pkgpart'},
+ 'keep_dates' => $keep_dates
+ );
+ if ( !ref($error) ) { # change returns the cust_pkg on success
+ $dbh->rollback;
+ die "Error changing pkgnum ".$old_cust_pkg->pkgnum.": '$error'\n";
+ }
+ }
+ $dbh->commit if $oldAutoCommit;
+ return;
+}
+
=item last_bill
Returns the last bill date, or if there is no last bill date, the setup date.
@@ -1369,6 +1442,18 @@ sub calc_recur {
$self->part_pkg->calc_recur($self, @_);
}
+=item base_recur
+
+Calls the I<base_recur> of the FS::part_pkg object associated with this billing
+item.
+
+=cut
+
+sub base_recur {
+ my $self = shift;
+ $self->part_pkg->base_recur($self, @_);
+}
+
=item calc_remain
Calls the I<calc_remain> of the FS::part_pkg object associated with this
@@ -1794,7 +1879,7 @@ Class method that returns the list of possible status strings for packages
=cut
tie my %statuscolor, 'Tie::IxHash',
- 'not yet billed' => '000000',
+ 'not yet billed' => '009999', #teal? cyan?
'one-time charge' => '000000',
'active' => '00CC00',
'suspended' => 'FF9900',
@@ -2524,6 +2609,22 @@ sub cancel_sql {
"cust_pkg.cancel IS NOT NULL AND cust_pkg.cancel != 0";
}
+=item status_sql
+
+Returns an SQL expression to give the package status as a string.
+
+=cut
+
+sub status_sql {
+"CASE
+ WHEN cust_pkg.cancel IS NOT NULL THEN 'cancelled'
+ WHEN cust_pkg.susp IS NOT NULL THEN 'suspended'
+ WHEN cust_pkg.setup IS NULL THEN 'not yet billed'
+ WHEN ".onetime_sql()." THEN 'one-time charge'
+ ELSE 'active'
+END"
+}
+
=item search HASHREF
(Class method)
@@ -2624,6 +2725,15 @@ sub search {
}
##
+ # custbatch
+ ##
+
+ if ( $params->{'pkgbatch'} =~ /^([\w\/\-\:\.]+)$/ and $1 ) {
+ push @where,
+ "cust_pkg.pkgbatch = '$1'";
+ }
+
+ ##
# parse status
##
@@ -2783,7 +2893,7 @@ sub search {
"NOT (".FS::cust_pkg->onetime_sql . ")";
}
else {
- foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
+ foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
next unless exists($params->{$field});
@@ -3208,6 +3318,9 @@ sub bulk_change {
sub _upgrade_data { # class method
my ($class, %opts) = @_;
$class->_upgrade_otaker(%opts);
+ my $sql =('UPDATE cust_pkg SET contract_end = NULL WHERE contract_end = -1');
+ my $sth = dbh->prepare($sql);
+ $sth->execute or die $sth->errstr;
}
=back
diff --git a/FS/FS/cust_pkg/Import.pm b/FS/FS/cust_pkg/Import.pm
new file mode 100644
index 0000000..43470a4
--- /dev/null
+++ b/FS/FS/cust_pkg/Import.pm
@@ -0,0 +1,373 @@
+package FS::cust_pkg::Import;
+
+use strict;
+use vars qw( $DEBUG ); #$conf );
+use Storable qw(thaw);
+use Data::Dumper;
+use MIME::Base64;
+use FS::Misc::DateTime qw( parse_datetime );
+use FS::Record qw( qsearchs );
+use FS::cust_pkg;
+use FS::cust_main;
+use FS::svc_acct;
+use FS::svc_external;
+use FS::svc_phone;
+
+$DEBUG = 0;
+
+#install_callback FS::UID sub {
+# $conf = new FS::Conf;
+#};
+
+=head1 NAME
+
+FS::cust_pkg::Import - Batch customer importing
+
+=head1 SYNOPSIS
+
+ use FS::cust_pkg::Import;
+
+ #import
+ FS::cust_pkg::Import::batch_import( {
+ file => $file, #filename
+ type => $type, #csv or xls
+ format => $format, #extended, extended-plus_company, svc_external,
+ # or svc_external_svc_phone
+ agentnum => $agentnum,
+ job => $job, #optional job queue job, for progressbar updates
+ pkgbatch => $pkgbatch, #optional batch unique identifier
+ } );
+ die $error if $error;
+
+ #ajax helper
+ use FS::UI::Web::JSRPC;
+ my $server =
+ new FS::UI::Web::JSRPC 'FS::cust_pkg::Import::process_batch_import', $cgi;
+ print $server->process;
+
+=head1 DESCRIPTION
+
+Batch package importing.
+
+=head1 SUBROUTINES
+
+=item process_batch_import
+
+Load a batch import as a queued JSRPC job
+
+=cut
+
+sub process_batch_import {
+ my $job = shift;
+
+ my $param = thaw(decode_base64(shift));
+ warn Dumper($param) if $DEBUG;
+
+ my $files = $param->{'uploaded_files'}
+ or die "No files provided.\n";
+
+ my (%files) = map { /^(\w+):([\.\w]+)$/ ? ($1,$2):() } split /,/, $files;
+
+ my $dir = '%%%FREESIDE_CACHE%%%/cache.'. $FS::UID::datasrc. '/';
+ my $file = $dir. $files{'file'};
+
+ my $type;
+ if ( $file =~ /\.(\w+)$/i ) {
+ $type = lc($1);
+ } else {
+ #or error out???
+ warn "can't parse file type from filename $file; defaulting to CSV";
+ $type = 'csv';
+ }
+
+ my $error =
+ FS::cust_pkg::Import::batch_import( {
+ job => $job,
+ file => $file,
+ type => $type,
+ 'params' => { pkgbatch => $param->{pkgbatch} },
+ agentnum => $param->{'agentnum'},
+ 'format' => $param->{'format'},
+ } );
+
+ unlink $file;
+
+ die "$error\n" if $error;
+
+}
+
+=item batch_import
+
+=cut
+
+my %formatfields = (
+ 'default' => [],
+ 'svc_acct' => [qw( username _password domsvc )],
+ 'svc_phone' => [qw( countrycode phonenum sip_password pin )],
+ 'svc_external' => [qw( id title )],
+);
+
+sub _formatfields {
+ \%formatfields;
+}
+
+my %import_options = (
+ 'table' => 'cust_pkg',
+
+ 'postinsert_callback' => sub {
+ my( $record, $param ) = @_;
+
+ my $formatfields = _formatfields;
+ foreach my $svc_x ( grep { $_ ne 'default' } keys %$formatfields ) {
+
+ my $ff = $formatfields->{$svc_x};
+
+ if ( grep $param->{"$svc_x.$_"}, @$ff ) {
+ my $svc_x = "FS::$svc_x"->new( {
+ 'pkgnum' => $record->pkgnum,
+ 'svcpart' => $record->part_pkg->svcpart($svc_x),
+ map { $_ => $param->{"$svc_x.$_"} } @$ff
+ } );
+ my $error = $svc_x->insert;
+ return $error if $error;
+ }
+
+ }
+
+ return ''; #no error
+
+ },
+);
+
+sub _import_options {
+ \%import_options;
+}
+
+sub batch_import {
+ my $opt = shift;
+
+ my $iopt = _import_options;
+ $opt->{$_} = $iopt->{$_} foreach keys %$iopt;
+
+ my $agentnum = delete $opt->{agentnum}; # i like closures (delete though?)
+
+ my $format = delete $opt->{'format'};
+ my @fields = ();
+
+ if ( $format =~ /^(.*)-agent_custid$/ ) {
+ $format = $1;
+ @fields = (
+ sub {
+ my( $self, $value ) = @_; # $conf, $param
+ my $cust_main = qsearchs('cust_main', {
+ 'agentnum' => $agentnum,
+ 'agent_custid' => $value,
+ });
+ $self->custnum($cust_main->custnum) if $cust_main;
+ },
+ );
+ } else {
+ @fields = ( 'custnum' );
+ }
+
+ push @fields, ( 'pkgpart', 'discountnum' );
+
+ foreach my $field (
+ qw( start_date setup bill last_bill susp adjourn cancel expire )
+ ) {
+ push @fields, sub {
+ my( $self, $value ) = @_; # $conf, $param
+ #->$field has undesirable effects
+ $self->set($field, parse_datetime($value) ); #$field closure
+ };
+ }
+
+ my $formatfields = _formatfields();
+
+ die "unknown format $format" unless $formatfields->{$format};
+
+ foreach my $field ( @{ $formatfields->{$format} } ) {
+
+ push @fields, sub {
+ my( $self, $value, $conf, $param ) = @_;
+ $param->{"$format.$field"} = $value;
+ };
+
+ }
+
+ $opt->{'fields'} = \@fields;
+
+ FS::Record::batch_import( $opt );
+
+}
+
+=for comment
+
+ my $billtime = time;
+ my %cust_pkg = ( pkgpart => $pkgpart );
+ my %svc_x = ();
+ foreach my $field ( @fields ) {
+
+ if ( $field =~ /^cust_pkg\.(pkgpart|setup|bill|susp|adjourn|expire|cancel)$/ ) {
+
+ #$cust_pkg{$1} = parse_datetime( shift @$columns );
+ if ( $1 eq 'pkgpart' ) {
+ $cust_pkg{$1} = shift @columns;
+ } elsif ( $1 eq 'setup' ) {
+ $billtime = parse_datetime(shift @columns);
+ } else {
+ $cust_pkg{$1} = parse_datetime( shift @columns );
+ }
+
+ } elsif ( $field =~ /^svc_acct\.(username|_password)$/ ) {
+
+ $svc_x{$1} = shift @columns;
+
+ } elsif ( $field =~ /^svc_external\.(id|title)$/ ) {
+
+ $svc_x{$1} = shift @columns;
+
+ } elsif ( $field =~ /^svc_phone\.(countrycode|phonenum|sip_password|pin)$/ ) {
+ $svc_x{$1} = shift @columns;
+
+ } else {
+
+ #refnum interception
+ if ( $field eq 'refnum' && $columns[0] !~ /^\s*(\d+)\s*$/ ) {
+
+ my $referral = $columns[0];
+ my %hash = ( 'referral' => $referral,
+ 'agentnum' => $agentnum,
+ 'disabled' => '',
+ );
+
+ my $part_referral = qsearchs('part_referral', \%hash )
+ || new FS::part_referral \%hash;
+
+ unless ( $part_referral->refnum ) {
+ my $error = $part_referral->insert;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't auto-insert advertising source: $referral: $error";
+ }
+ }
+
+ $columns[0] = $part_referral->refnum;
+ }
+
+ my $value = shift @columns;
+ $cust_main{$field} = $value if length($value);
+ }
+ }
+
+ $cust_main{'payby'} = 'CARD'
+ if defined $cust_main{'payinfo'}
+ && length $cust_main{'payinfo'};
+
+ my $invoicing_list = $cust_main{'invoicing_list'}
+ ? [ delete $cust_main{'invoicing_list'} ]
+ : [];
+
+ my $cust_main = new FS::cust_main ( \%cust_main );
+
+ use Tie::RefHash;
+ tie my %hash, 'Tie::RefHash'; #this part is important
+
+ if ( $cust_pkg{'pkgpart'} ) {
+ my $cust_pkg = new FS::cust_pkg ( \%cust_pkg );
+
+ my @svc_x = ();
+ my $svcdb = '';
+ if ( $svc_x{'username'} ) {
+ $svcdb = 'svc_acct';
+ } elsif ( $svc_x{'id'} || $svc_x{'title'} ) {
+ $svcdb = 'svc_external';
+ }
+
+ my $svc_phone = '';
+ if ( $svc_x{'countrycode'} || $svc_x{'phonenum'} ) {
+ $svc_phone = FS::svc_phone->new( {
+ map { $_ => delete($svc_x{$_}) }
+ qw( countrycode phonenum sip_password pin)
+ } );
+ }
+
+ if ( $svcdb || $svc_phone ) {
+ my $part_pkg = $cust_pkg->part_pkg;
+ unless ( $part_pkg ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "unknown pkgpart: ". $cust_pkg{'pkgpart'};
+ }
+ if ( $svcdb ) {
+ $svc_x{svcpart} = $part_pkg->svcpart_unique_svcdb( $svcdb );
+ my $class = "FS::$svcdb";
+ push @svc_x, $class->new( \%svc_x );
+ }
+ if ( $svc_phone ) {
+ $svc_phone->svcpart( $part_pkg->svcpart_unique_svcdb('svc_phone') );
+ push @svc_x, $svc_phone;
+ }
+ }
+
+ $hash{$cust_pkg} = \@svc_x;
+ }
+
+ my $error = $cust_main->insert( \%hash, $invoicing_list );
+
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't insert customer". ( $line ? " for $line" : '' ). ": $error";
+ }
+
+ if ( $format eq 'simple' ) {
+
+ #false laziness w/bill.cgi
+ $error = $cust_main->bill( 'time' => $billtime );
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't bill customer for $line: $error";
+ }
+
+ $error = $cust_main->apply_payments_and_credits;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't bill customer for $line: $error";
+ }
+
+ $error = $cust_main->collect();
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return "can't collect customer for $line: $error";
+ }
+
+ }
+
+ $row++;
+
+ if ( $job && time - $min_sec > $last ) { #progress bar
+ $job->update_statustext( int(100 * $row / $count) );
+ $last = time;
+ }
+
+ }
+
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;;
+
+ return "Empty file!" unless $row;
+
+ ''; #no error
+
+}
+
+=head1 BUGS
+
+Not enough documentation.
+
+=head1 SEE ALSO
+
+L<FS::cust_main>, L<FS::cust_pkg>,
+L<FS::svc_acct>, L<FS::svc_external>, L<FS::svc_phone>
+
+=cut
+
+1;
diff --git a/FS/FS/cust_pkg_reason.pm b/FS/FS/cust_pkg_reason.pm
index 1ec5024..72a2319 100644
--- a/FS/FS/cust_pkg_reason.pm
+++ b/FS/FS/cust_pkg_reason.pm
@@ -1,9 +1,12 @@
package FS::cust_pkg_reason;
use strict;
+use vars qw( $ignore_empty_action );
use base qw( FS::otaker_Mixin FS::Record );
use FS::Record qw( qsearch qsearchs );
+$ignore_empty_action = 0;
+
=head1 NAME
FS::cust_pkg_reason - Object methods for cust_pkg_reason records
@@ -93,11 +96,14 @@ and replace methods.
sub check {
my $self = shift;
+ my @actions = ( 'A', 'C', 'E', 'S' );
+ push @actions, '' if $ignore_empty_action;
+
my $error =
$self->ut_numbern('num')
|| $self->ut_number('pkgnum')
|| $self->ut_number('reasonnum')
- || $self->ut_enum('action', [ 'A', 'C', 'E', 'S' ])
+ || $self->ut_enum('action', \@actions)
|| $self->ut_alphan('otaker')
|| $self->ut_numbern('date')
;
@@ -135,13 +141,10 @@ sub reasontext {
use FS::h_cust_pkg;
use FS::h_cust_pkg_reason;
-use FS::Schema qw(dbdef);
sub _upgrade_data { # class method
my ($class, %opts) = @_;
- return '' unless dbdef->table('cust_pkg_reason')->column('action');
-
my $action_replace =
" AND ( history_action = 'replace_old' OR history_action = 'replace_new' )";
@@ -306,6 +309,9 @@ sub _upgrade_data { # class method
}
}
+ #still can't fill in an action? don't abort the upgrade
+ local($ignore_empty_action) = 1;
+
$class->_upgrade_otaker(%opts);
}
diff --git a/FS/FS/cust_refund.pm b/FS/FS/cust_refund.pm
index 3960370..7df7a55 100644
--- a/FS/FS/cust_refund.pm
+++ b/FS/FS/cust_refund.pm
@@ -7,6 +7,7 @@ use vars qw( @encrypted_fields );
use Business::CreditCard;
use FS::UID qw(getotaker);
use FS::Record qw( qsearch qsearchs dbh );
+use FS::CurrentUser;
use FS::cust_credit;
use FS::cust_credit_refund;
use FS::cust_pay_refund;
@@ -237,12 +238,17 @@ sub delete {
=item replace OLD_RECORD
-Modifying a refund? Well, don't say I didn't warn you.
+You can, but probably shouldn't modify refunds...
+
+Replaces the OLD_RECORD with this one in the database, or, if OLD_RECORD is not
+supplied, replaces this record. If there is an error, returns the error,
+otherwise returns false.
=cut
sub replace {
my $self = shift;
+ return "Can't modify closed refund" if $self->closed =~ /^Y/i;
$self->SUPER::replace(@_);
}
@@ -256,7 +262,7 @@ returns the error, otherwise returns false. Called by the insert method.
sub check {
my $self = shift;
- $self->otaker(getotaker) unless $self->otaker;
+ $self->usernum($FS::CurrentUser::CurrentUser->usernum) unless $self->usernum;
my $error =
$self->ut_numbern('refundnum')
diff --git a/FS/FS/cust_svc.pm b/FS/FS/cust_svc.pm
index c0766e5..7b866fa 100644
--- a/FS/FS/cust_svc.pm
+++ b/FS/FS/cust_svc.pm
@@ -539,15 +539,24 @@ sub seconds_since_sqlradacct {
warn "$mes finding closed sessions completely within the given range\n"
if $DEBUG;
+ my $realm = '';
+ my $realmparam = '';
+ if ($part_export->option('process_single_realm')) {
+ $realm = 'AND Realm = ?';
+ $realmparam = $part_export->option('realm');
+ }
+
my $sth = $dbh->prepare("SELECT SUM(acctsessiontime)
FROM radacct
WHERE UserName = ?
+ $realm
AND $str2time AcctStartTime) >= ?
AND $str2time AcctStopTime ) < ?
AND $str2time AcctStopTime ) > 0
AND AcctStopTime IS NOT NULL"
) or die $dbh->errstr;
- $sth->execute($username, $start, $end) or die $sth->errstr;
+ $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
+ or die $sth->errstr;
my $regular = $sth->fetchrow_arrayref->[0];
warn "$mes finding open sessions which start in the range\n"
@@ -557,13 +566,19 @@ sub seconds_since_sqlradacct {
$query = "SELECT SUM( ? - $str2time AcctStartTime ) )
FROM radacct
WHERE UserName = ?
+ $realm
AND $str2time AcctStartTime ) >= ?
AND $str2time AcctStartTime ) < ?
AND ( ? - $str2time AcctStartTime ) ) < 86400
AND ( $str2time AcctStopTime ) = 0
OR AcctStopTime IS NULL )";
$sth = $dbh->prepare($query) or die $dbh->errstr;
- $sth->execute($end, $username, $start, $end, $end)
+ $sth->execute( $end,
+ $username,
+ ($realm ? $realmparam : ()),
+ $start,
+ $end,
+ $end )
or die $sth->errstr. " executing query $query";
my $start_during = $sth->fetchrow_arrayref->[0];
@@ -574,13 +589,20 @@ sub seconds_since_sqlradacct {
$sth = $dbh->prepare("SELECT SUM( $str2time AcctStopTime ) - ? )
FROM radacct
WHERE UserName = ?
+ $realm
AND $str2time AcctStartTime ) < ?
AND $str2time AcctStopTime ) >= ?
AND $str2time AcctStopTime ) < ?
AND $str2time AcctStopTime ) > 0
AND AcctStopTime IS NOT NULL"
) or die $dbh->errstr;
- $sth->execute($start, $username, $start, $start, $end ) or die $sth->errstr;
+ $sth->execute( $start,
+ $username,
+ ($realm ? $realmparam : ()),
+ $start,
+ $start,
+ $end )
+ or die $sth->errstr;
my $end_during = $sth->fetchrow_arrayref->[0];
warn "$mes finding closed sessions which start before the range but stop after\n"
@@ -591,13 +613,15 @@ sub seconds_since_sqlradacct {
$sth = $dbh->prepare("SELECT COUNT(*)
FROM radacct
WHERE UserName = ?
+ $realm
AND $str2time AcctStartTime ) < ?
AND ( $str2time AcctStopTime ) >= ?
)"
# OR AcctStopTime = 0
# OR AcctStopTime IS NULL )"
) or die $dbh->errstr;
- $sth->execute($username, $start, $end ) or die $sth->errstr;
+ $sth->execute($username, ($realm ? $realmparam : ()), $start, $end )
+ or die $sth->errstr;
my $entire_range = ($end-$start) * $sth->fetchrow_arrayref->[0];
$seconds += $regular + $end_during + $start_during + $entire_range;
@@ -658,14 +682,23 @@ sub attribute_since_sqlradacct {
warn "$mes SUMing $attrib sessions\n"
if $DEBUG;
+ my $realm = '';
+ my $realmparam = '';
+ if ($part_export->option('process_single_realm')) {
+ $realm = 'AND Realm = ?';
+ $realmparam = $part_export->option('realm');
+ }
+
my $sth = $dbh->prepare("SELECT SUM($attrib)
FROM radacct
WHERE UserName = ?
+ $realm
AND $str2time AcctStopTime ) >= ?
AND $str2time AcctStopTime ) < ?
AND AcctStopTime IS NOT NULL"
) or die $dbh->errstr;
- $sth->execute($username, $start, $end) or die $sth->errstr;
+ $sth->execute($username, ($realm ? $realmparam : ()), $start, $end)
+ or die $sth->errstr;
my $row = $sth->fetchrow_arrayref;
$sum += $row->[0] if defined($row->[0]);
diff --git a/FS/FS/discount.pm b/FS/FS/discount.pm
index 8afeb2e..4f42c5b 100644
--- a/FS/FS/discount.pm
+++ b/FS/FS/discount.pm
@@ -133,6 +133,17 @@ sub check {
;
return $error if $error;
+ #discourage non-integer months for package discounts
+ if ($self->discountnum) {
+ my $sql =
+ "SELECT count(*) FROM part_pkg_discount WHERE part_pkg_discount.discountnum = ".
+ $self->discountnum;
+
+ my $count = $self->scalar_sql($sql);
+ return "months must be integers greater than 1"
+ if ( $count && ($self->ut_number('months') || $self->months < 2) );
+ }
+
$self->SUPER::check;
}
diff --git a/FS/FS/domain_record.pm b/FS/FS/domain_record.pm
index 6513abf..8d767d5 100644
--- a/FS/FS/domain_record.pm
+++ b/FS/FS/domain_record.pm
@@ -1,7 +1,7 @@
package FS::domain_record;
use strict;
-use vars qw( @ISA $noserial_hack $DEBUG );
+use vars qw( @ISA $noserial_hack $DEBUG $me );
use FS::Conf;
#use FS::Record qw( qsearch qsearchs );
use FS::Record qw( qsearchs dbh );
@@ -11,6 +11,7 @@ use FS::svc_www;
@ISA = qw(FS::Record);
$DEBUG = 0;
+$me = '[FS::domain_record]';
=head1 NAME
@@ -51,6 +52,8 @@ supported:
=item recdata - data for this entry
+=item ttl - time to live
+
=back
=head1 METHODS
@@ -265,10 +268,12 @@ sub check {
$self->recaf =~ /^(IN)$/ or return "Illegal recaf: ". $self->recaf;
$self->recaf($1);
- $self->rectype =~ /^(SOA|NS|MX|A|PTR|CNAME|TXT|_mstr)$/
- or return "Illegal rectype (only SOA NS MX A PTR CNAME TXT recognized): ".
- $self->rectype;
- $self->rectype($1);
+ $self->ttl =~ /^([0-9]{0,6})$/ or return "Illegal ttl: ". $self->ttl;
+ $self->ttl($1);
+
+ my %rectypes = map { $_=>1 } ( @{ $self->rectypes }, '_mstr' );
+ return 'Illegal rectype: '. $self->rectype
+ unless exists $rectypes{$self->rectype} && $rectypes{$self->rectype};
return "Illegal reczone for ". $self->rectype. ": ". $self->reczone
if $self->rectype !~ /^MX$/i && $self->reczone =~ /\*/;
@@ -291,6 +296,10 @@ sub check {
$self->recdata =~ /^((\d{1,3}\.){3}\d{1,3})$/
or return "Illegal data for A record: ". $self->recdata;
$self->recdata($1);
+ } elsif ( $self->rectype eq 'AAAA' ) {
+ $self->recdata =~ /^([\da-z:]+)$/
+ or return "Illegal data for AAAA record: ". $self->recdata;
+ $self->recdata($1);
} elsif ( $self->rectype eq 'PTR' ) {
if ( $conf->exists('zone-underscore') ) {
$self->recdata =~ /^([a-z0-9_\.\-]+)$/i
@@ -312,11 +321,17 @@ sub check {
$self->recdata('"'. $self->recdata. '"'); #?
}
# or return "Illegal data for TXT record: ". $self->recdata;
+ } elsif ( $self->rectype eq 'SRV' ) {
+ $self->recdata =~ /^(\d+)\s+(\d+)\s+(\d+)\s+([a-z0-9\.\-]+)$/i
+ or return "Illegal data for SRV record: ". $self->recdata;
+ $self->recdata("$1 $2 $3 $4");
} elsif ( $self->rectype eq '_mstr' ) {
$self->recdata =~ /^((\d{1,3}\.){3}\d{1,3})$/
or return "Illegal data for _master pseudo-record: ". $self->recdata;
} else {
- die "ack!";
+ warn "$me no specific check for ". $self->rectype. " records yet";
+ $error = $self->ut_text('recdata');
+ return $error if $error;
}
$self->SUPER::check;
@@ -419,6 +434,18 @@ sub reverse_record {
or new FS::domain_record { %hash, 'recdata' => $self->zone.'.' };
}
+=item rectypes
+
+=cut
+#http://en.wikipedia.org/wiki/List_of_DNS_record_types
+#DHCID? other things?
+sub rectypes {
+ [ qw(SOA A AAAA CNAME MX NS PTR SPF SRV TXT), #most common types
+ #qw(DNAME), #uncommon types
+ qw(DLV DNSKEY DS NSEC NSEC3 NSEC3PARAM RRSIG), #DNSSEC types
+ ];
+}
+
=back
=head1 BUGS
diff --git a/FS/FS/geocode_Mixin.pm b/FS/FS/geocode_Mixin.pm
new file mode 100644
index 0000000..c153914
--- /dev/null
+++ b/FS/FS/geocode_Mixin.pm
@@ -0,0 +1,163 @@
+package FS::geocode_Mixin;
+
+use strict;
+use vars qw( $DEBUG $me );
+use Carp;
+use Locale::Country;
+use FS::Record qw( qsearchs qsearch );
+use FS::Conf;
+use FS::cust_pkg;
+use FS::cust_location;
+use FS::cust_tax_location;
+use FS::part_pkg;
+
+$DEBUG = 0;
+$me = '[FS::geocode_Mixin]';
+
+=head1 NAME
+
+FS::geocode_Mixin - Mixin class for records that contain address and other
+location fields.
+
+=head1 SYNOPSIS
+
+ package FS::some_table;
+ use base ( FS::geocode_Mixin FS::Record );
+
+=head1 DESCRIPTION
+
+FS::geocode_Mixin - This is a mixin class for records that contain address
+and other location fields.
+
+=head1 METHODS
+
+=over 4
+
+=cut
+
+=item location_hash
+
+Returns a list of key/value pairs, with the following keys: address1, address2,
+city, county, state, zip, country. The shipping address is used if present.
+
+=cut
+
+#geocode dependent on tax-ship_address config
+
+sub location_hash {
+ my $self = shift;
+ my $prefix = $self->has_ship_address ? 'ship_' : '';
+
+ map { my $method = ($_ eq 'geocode') ? $_ : $prefix.$_;
+ $_ => $self->get($method);
+ }
+ qw( address1 address2 city county state zip country geocode );
+}
+
+=item location_label [ OPTION => VALUE ... ]
+
+Returns the label of the service location (see analog in L<FS::cust_location>) for this customer.
+
+Options are
+
+=over 4
+
+=item join_string
+
+used to separate the address elements (defaults to ', ')
+
+=item escape_function
+
+a callback used for escaping the text of the address elements
+
+=back
+
+=cut
+
+sub location_label {
+ my $self = shift;
+ my %opt = @_;
+
+ my $separator = $opt{join_string} || ', ';
+ my $escape = $opt{escape_function} || sub{ shift };
+ my $ds = $opt{double_space} || ' ';
+ my $line = '';
+ my $cydefault =
+ $opt{'countrydefault'} || FS::Conf->new->config('countrydefault') || 'US';
+ my $prefix = $self->has_ship_address ? 'ship_' : '';
+
+ my $notfirst = 0;
+ foreach (qw ( address1 address2 ) ) {
+ my $method = "$prefix$_";
+ $line .= ($notfirst ? $separator : ''). &$escape($self->$method)
+ if $self->$method;
+ $notfirst++;
+ }
+ $notfirst = 0;
+ foreach (qw ( city county state zip ) ) {
+ my $method = "$prefix$_";
+ if ( $self->$method ) {
+ $line .= ' (' if $method eq 'county';
+ $line .= ($notfirst ? ' ' : $separator). &$escape($self->$method);
+ $line .= ' )' if $method eq 'county';
+ $notfirst++;
+ }
+ }
+ $line .= $separator. &$escape(code2country($self->country))
+ if $self->country ne $cydefault;
+
+ $line;
+}
+
+=item geocode DATA_VENDOR
+
+Returns a value for the customer location as encoded by DATA_VENDOR.
+Currently this only makes sense for "CCH" as DATA_VENDOR.
+
+=cut
+
+sub geocode {
+ my ($self, $data_vendor) = (shift, shift); #always cch for now
+
+ my $geocode = $self->get('geocode'); #XXX only one data_vendor for geocode
+ return $geocode if $geocode;
+
+ my $prefix =
+ ( FS::Conf->new->exists('tax-ship_address') && $self->has_ship_address )
+ ? 'ship_'
+ : '';
+
+ my($zip,$plus4) = split /-/, $self->get("${prefix}zip")
+ if $self->country eq 'US';
+
+ $zip ||= '';
+ $plus4 ||= '';
+ #CCH specific location stuff
+ my $extra_sql = "AND plus4lo <= '$plus4' AND plus4hi >= '$plus4'";
+
+ my @cust_tax_location =
+ qsearch( {
+ 'table' => 'cust_tax_location',
+ 'hashref' => { 'zip' => $zip, 'data_vendor' => $data_vendor },
+ 'extra_sql' => $extra_sql,
+ 'order_by' => 'ORDER BY plus4hi',#overlapping with distinct ends
+ }
+ );
+ $geocode = $cust_tax_location[0]->geocode
+ if scalar(@cust_tax_location);
+
+ $geocode;
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/msg_template.pm b/FS/FS/msg_template.pm
index 7d507f4..270eb1f 100644
--- a/FS/FS/msg_template.pm
+++ b/FS/FS/msg_template.pm
@@ -166,7 +166,18 @@ Customer object (required).
=item object
Additional context object (currently, can be a cust_main, cust_pkg,
-cust_bill, svc_acct, or cust_pay object).
+cust_bill, svc_acct, cust_pay, or cust_pay_pending). If the object
+is a svc_acct, its cust_pkg will be fetched and used for substitution.
+
+As a special case, this may be an arrayref of two objects. Both
+objects will be available for substitution, with their field names
+prefixed with 'new_' and 'old_' respectively. This is used in the
+rt_ticket export when exporting "replace" events.
+
+=item to
+
+Destination address. The default is to use the customer's
+invoicing_list addresses.
=back
@@ -186,15 +197,37 @@ sub prepare {
# create substitution table
###
my %hash;
- foreach my $obj ($cust_main, $object || ()) {
+ my @objects = ($cust_main);
+ my @prefixes = ('');
+ my $svc;
+ if( ref $object ) {
+ if( ref($object) eq 'ARRAY' ) {
+ # [new, old], for provisioning tickets
+ push @objects, $object->[0], $object->[1];
+ push @prefixes, 'new_', 'old_';
+ $svc = $object->[0] if $object->[0]->isa('FS::svc_Common');
+ }
+ else {
+ push @objects, $object;
+ push @prefixes, '';
+ $svc = $object if $object->isa('FS::svc_Common');
+ }
+ }
+ if( $svc ) {
+ push @objects, $svc->cust_svc->cust_pkg;
+ push @prefixes, '';
+ }
+
+ foreach my $obj (@objects) {
+ my $prefix = shift @prefixes;
foreach my $name (@{ $subs->{$obj->table} }) {
if(!ref($name)) {
# simple case
- $hash{$name} = $obj->$name();
+ $hash{$prefix.$name} = $obj->$name();
}
elsif( ref($name) eq 'ARRAY' ) {
# [ foo => sub { ... } ]
- $hash{$name->[0]} = $name->[1]->($obj);
+ $hash{$prefix.($name->[0])} = $name->[1]->($obj);
}
else {
warn "bad msg_template substitution: '$name'\n";
@@ -226,7 +259,7 @@ sub prepare {
$_
} @$guts;
- $body = '';
+ $body = '{ use Date::Format qw(time2str); "" }';
while(@$skin || @$guts) {
$body .= shift(@$skin) || '';
$body .= shift(@$guts) || '';
@@ -247,7 +280,7 @@ sub prepare {
# and email
###
- my @to = $cust_main->invoicing_list_emailonly;
+ my @to = ($opt{'to'}) || $cust_main->invoicing_list_emailonly;
warn "prepared msg_template with no email destination (custnum ".
$cust_main->custnum.")\n"
if !@to;
@@ -255,9 +288,10 @@ sub prepare {
my $conf = new FS::Conf;
(
- 'from' => $self->from ||
+ 'from' => $self->from_addr ||
scalar( $conf->config('invoice_from', $cust_main->agentnum) ),
'to' => \@to,
+ 'bcc' => $self->bcc_addr || undef,
'subject' => $subject,
'html_body' => $body,
'text_body' => HTML::FormatText->new(leftmargin => 0, rightmargin => 70
@@ -310,11 +344,15 @@ sub substitutions {
num_cancelled_pkgs num_ncancelled_pkgs num_pkgs
classname categoryname
balance
+ credit_limit
invoicing_list_emailonly
cust_status ucfirst_cust_status cust_statuscolor
signupdate dundate
+ expdate
+ packages recurdates
),
+ # expdate is a special case
[ signupdate_ymd => sub { time2str('%Y-%m-%d', shift->signupdate) } ],
[ dundate_ymd => sub { time2str('%Y-%m-%d', shift->dundate) } ],
[ paydate_my => sub { sprintf('%02d/%04d', shift->paydate_monthyear) } ],
@@ -324,10 +362,13 @@ sub substitutions {
[ company_name => sub {
$conf->config('company_name', shift->agentnum)
} ],
+ [ company_address => sub {
+ $conf->config('company_address', shift->agentnum)
+ } ],
],
# next_bill_date
'cust_pkg' => [qw(
- pkgnum pkg_label pkg_label_long
+ pkgnum pkg pkg_label pkg_label_long
location_label
status statuscolor
@@ -351,11 +392,15 @@ sub substitutions {
)],
#XXX not really thinking about cust_bill substitutions quite yet
+ # for welcome and limit warning messages
'svc_acct' => [qw(
+ svcnum
username
+ domain
),
[ password => sub { shift->getfield('_password') } ],
- ], # for welcome messages
+ ],
+ # for payment receipts
'cust_pay' => [qw(
paynum
_date
@@ -370,6 +415,22 @@ sub substitutions {
$cust_pay->paymask : $cust_pay->decrypt($cust_pay->payinfo)
} ],
],
+ # for payment decline messages
+ # try to support all cust_pay fields
+ # 'error' is a special case, it contains the raw error from the gateway
+ 'cust_pay_pending' => [qw(
+ _date
+ error
+ ),
+ [ paid => sub { sprintf("%.2f", shift->paid) } ],
+ [ payby => sub { FS::payby->shortname(shift->payby) } ],
+ [ date => sub { time2str("%a %B %o, %Y", shift->_date) } ],
+ [ payinfo => sub {
+ my $pending = shift;
+ ($pending->payby eq 'CARD' || $pending->payby eq 'CHEK') ?
+ $pending->paymask : $pending->decrypt($pending->payinfo)
+ } ],
+ ],
};
}
@@ -377,26 +438,27 @@ sub _upgrade_data {
my ($self, %opts) = @_;
my @fixes = (
- [ 'alerter_msgnum', 'alerter_template', '', '' ],
- [ 'cancel_msgnum', 'cancelmessage', 'cancelsubject', '' ],
- [ 'decline_msgnum', 'declinetemplate', '', '' ],
- [ 'impending_recur_msgnum', 'impending_recur_template', '', '' ],
- [ 'payment_receipt_msgnum', 'payment_receipt_email', '', '' ],
- [ 'welcome_msgnum', 'welcome_email', 'welcome_email-subject', 'welcome_email-from' ],
- [ 'warning_msgnum', 'warning_email', 'warning_email-subject', 'warning_email-from' ],
+ [ 'alerter_msgnum', 'alerter_template', '', '', '' ],
+ [ 'cancel_msgnum', 'cancelmessage', 'cancelsubject', '', '' ],
+ [ 'decline_msgnum', 'declinetemplate', '', '', '' ],
+ [ 'impending_recur_msgnum', 'impending_recur_template', '', '', 'impending_recur_bcc' ],
+ [ 'payment_receipt_msgnum', 'payment_receipt_email', '', '', '' ],
+ [ 'welcome_msgnum', 'welcome_email', 'welcome_email-subject', 'welcome_email-from', '' ],
+ [ 'warning_msgnum', 'warning_email', 'warning_email-subject', 'warning_email-from', '' ],
);
my $conf = new FS::Conf;
my @agentnums = ('', map {$_->agentnum} qsearch('agent', {}));
foreach my $agentnum (@agentnums) {
foreach (@fixes) {
- my ($newname, $oldname, $subject, $from) = @$_;
+ my ($newname, $oldname, $subject, $from, $bcc) = @$_;
if ($conf->exists($oldname, $agentnum)) {
my $new = new FS::msg_template({
'msgname' => $oldname,
'agentnum' => $agentnum,
'from_addr' => ($from && $conf->config($from, $agentnum)) ||
$conf->config('invoice_from', $agentnum),
+ 'bcc_addr' => ($bcc && $conf->config($from, $agentnum)) || '',
'subject' => ($subject && $conf->config($subject, $agentnum)) || '',
'mime_type' => 'text/html',
'body' => join('<BR>',$conf->config($oldname, $agentnum)),
diff --git a/FS/FS/option_Common.pm b/FS/FS/option_Common.pm
index a786ae3..26bb7ca 100644
--- a/FS/FS/option_Common.pm
+++ b/FS/FS/option_Common.pm
@@ -173,10 +173,15 @@ sub replace {
? shift
: $self->replace_old;
- my $options =
- ( ref($_[0]) eq 'HASH' )
- ? shift
- : { @_ };
+ my $options;
+ my $options_supplied = 0;
+ if ( ref($_[0]) eq 'HASH' ) {
+ $options = shift;
+ $options_supplied = 1;
+ } else {
+ $options = { @_ };
+ $options_supplied = scalar(@_) ? 1 : 0;
+ }
warn "FS::option_Common::replace called on $self with options ".
join(', ', map "$_ => ". $options->{$_}, keys %$options)
@@ -252,13 +257,15 @@ sub replace {
}
#remove extraneous old options
- foreach my $opt (
- grep { !exists $options->{$_->$namecol()} } $old->option_objects
- ) {
- my $error = $opt->delete;
- if ( $error ) {
- $dbh->rollback if $oldAutoCommit;
- return $error;
+ if ( $options_supplied ) {
+ foreach my $opt (
+ grep { !exists $options->{$_->$namecol()} } $old->option_objects
+ ) {
+ my $error = $opt->delete;
+ if ( $error ) {
+ $dbh->rollback if $oldAutoCommit;
+ return $error;
+ }
}
}
diff --git a/FS/FS/otaker_Mixin.pm b/FS/FS/otaker_Mixin.pm
index 0da9aee..8d9c882 100644
--- a/FS/FS/otaker_Mixin.pm
+++ b/FS/FS/otaker_Mixin.pm
@@ -13,6 +13,7 @@ sub otaker {
if ( !$access_user && $otaker =~ /^(.+), (.+)$/ ) { #same as below..
my($lastname, $firstname) = ($1, $2);
$otaker = lc($firstname.$lastname);
+ $otaker =~ s/ //g;
$access_user = qsearchs('access_user', { 'first' => $firstname,
'last' => $lastname } )
|| qsearchs('access_user', { 'username' => $otaker } );
@@ -58,6 +59,7 @@ sub _upgrade_otaker {
if ( $username =~ /^(.+), (.+)$/ ) {
($lastname, $firstname) = ($1, $2);
$username = lc($firstname.$lastname);
+ $username =~ s/ //g;
}
my $access_user = new FS::access_user {
'username' => $username,
diff --git a/FS/FS/part_event/Action/notice.pm b/FS/FS/part_event/Action/notice.pm
index 1269653..8e22c68 100644
--- a/FS/FS/part_event/Action/notice.pm
+++ b/FS/FS/part_event/Action/notice.pm
@@ -5,7 +5,7 @@ use base qw( FS::part_event::Action );
use FS::Record qw( qsearchs );
use FS::msg_template;
-sub description { 'Send a notice from a message template'; }
+sub description { 'Email a notice to the customer\'s billing address'; }
#sub eventtable_hashref {
# { 'cust_main' => 1,
diff --git a/FS/FS/part_event/Action/notice_to.pm b/FS/FS/part_event/Action/notice_to.pm
new file mode 100644
index 0000000..194aeb8
--- /dev/null
+++ b/FS/FS/part_event/Action/notice_to.pm
@@ -0,0 +1,55 @@
+package FS::part_event::Action::notice_to;
+
+use strict;
+use base qw( FS::part_event::Action );
+use FS::Record qw( qsearchs );
+use FS::msg_template;
+
+sub description { 'Email a notice to a specific address'; }
+
+#sub eventtable_hashref {
+# { 'cust_main' => 1,
+# 'cust_bill' => 1,
+# 'cust_pkg' => 1,
+# };
+#}
+
+sub option_fields {
+ (
+ 'to' => { 'label' => 'Destination',
+ 'type' => 'text',
+ 'size' => 30,
+ },
+ 'msgnum' => { 'label' => 'Template',
+ 'type' => 'select-table',
+ 'table' => 'msg_template',
+ 'name_col' => 'msgname',
+ 'disable_empty' => 1,
+ },
+ );
+}
+
+sub default_weight { 56; } #?
+
+sub do_action {
+ my( $self, $object ) = @_;
+
+ my $cust_main = $self->cust_main($object);
+
+ my $msgnum = $self->option('msgnum');
+
+ my $msg_template = qsearchs('msg_template', { 'msgnum' => $msgnum } )
+ or die "Template $msgnum not found";
+
+ my $to = $self->option('to')
+ or die "Can't send notice without a destination address";
+
+ $msg_template->send(
+ 'to' => $to,
+ 'cust_main' => $cust_main,
+ 'object' => $object,
+ );
+
+}
+
+1;
diff --git a/FS/FS/part_event/Condition.pm b/FS/FS/part_event/Condition.pm
index ddd8a61..90b8385 100644
--- a/FS/FS/part_event/Condition.pm
+++ b/FS/FS/part_event/Condition.pm
@@ -306,6 +306,28 @@ sub condition_sql_option {
)";
}
+#c.f. part_event_condition_option.pm / part_event_condition_option_option
+#used for part_event/Condition/payby.pm
+sub condition_sql_option_option {
+ my( $class, $option ) = @_;
+
+ ( my $condname = $class ) =~ s/^.*:://;
+
+ my $optionnum =
+ "( SELECT optionnum FROM part_event_condition_option
+ WHERE part_event_condition_option.eventconditionnum =
+ cond_$condname.eventconditionnum
+ AND part_event_condition_option.optionname = '$option'
+ AND part_event_condition_option.optionvalue = 'HASH'
+ )";
+
+ "( SELECT optionname FROM part_event_condition_option_option
+ WHERE optionnum = $optionnum
+ )";
+
+}
+
+
=item condition_sql_option_age_from OPTION FROM_TIMESTAMP
This is a class method that returns an SQL fragment that will retreive a
diff --git a/FS/FS/part_event/Condition/balance_age.pm b/FS/FS/part_event/Condition/balance_age.pm
index fc34612..8480659 100644
--- a/FS/FS/part_event/Condition/balance_age.pm
+++ b/FS/FS/part_event/Condition/balance_age.pm
@@ -45,8 +45,6 @@ sub order_sql {
shift->condition_sql_option_age('age');
}
-use FS::UID qw( driver_name );
-
sub order_sql_weight {
10;
}
diff --git a/FS/FS/part_event/Condition/balance_credit_limit.pm b/FS/FS/part_event/Condition/balance_credit_limit.pm
new file mode 100644
index 0000000..1bc2aa1
--- /dev/null
+++ b/FS/FS/part_event/Condition/balance_credit_limit.pm
@@ -0,0 +1,32 @@
+package FS::part_event::Condition::balance_credit_limit;
+
+use strict;
+use FS::cust_main;
+
+use base qw( FS::part_event::Condition );
+
+sub description { 'Customer is over credit limit'; }
+
+sub condition {
+ my($self, $object) = @_;
+
+ my $cust_main = $self->cust_main($object);
+
+ my $over = $cust_main->credit_limit;
+ return 0 if !length($over); # if credit limit is null, no limit
+
+ $cust_main->balance > $over;
+}
+
+sub condition_sql {
+ my( $class, $table ) = @_;
+
+ my $balance_sql = FS::cust_main->balance_sql;
+
+ "(cust_main.credit_limit IS NULL OR
+ $balance_sql - cust_main.credit_limit > 0 )";
+
+}
+
+1;
+
diff --git a/FS/FS/part_event/Condition/cust_bill_past_due.pm b/FS/FS/part_event/Condition/cust_bill_past_due.pm
new file mode 100644
index 0000000..a889a00
--- /dev/null
+++ b/FS/FS/part_event/Condition/cust_bill_past_due.pm
@@ -0,0 +1,41 @@
+package FS::part_event::Condition::cust_bill_past_due;
+
+use strict;
+use FS::cust_bill;
+use Time::Local 'timelocal';
+
+use base qw( FS::part_event::Condition );
+
+sub description {
+ 'Invoice due date has passed';
+}
+
+sub eventtable_hashref {
+ { 'cust_main' => 0,
+ 'cust_bill' => 1,
+ 'cust_pkg' => 0,
+ };
+}
+
+sub condition {
+ my($self, $cust_bill, %opt) = @_;
+
+ # If the invoice date is 1/1 at noon and the terms are Net 15,
+ # the due_date will be 1/16 at noon. Past due events will not
+ # trigger until after the start of 1/17.
+ my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($opt{'time'}))[0..5];
+ my $start_of_today = timelocal(0,0,0,$mday,$mon,$year)+1;
+ ($cust_bill->due_date || $cust_bill->_date) < $start_of_today;
+}
+
+sub condition_sql {
+ return 'true' if $FS::UID::driver_name ne 'Pg';
+ my( $class, $table, %opt ) = @_;
+ my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($opt{'time'}))[0..5];
+ my $start_of_today = timelocal(0,0,0,$mday,$mon,$year)+1;
+
+ FS::cust_bill->due_date_sql . " < $start_of_today";
+
+}
+
+1;
diff --git a/FS/FS/part_event/Condition/cust_status.pm b/FS/FS/part_event/Condition/cust_status.pm
index fbdff25..066ee48 100644
--- a/FS/FS/part_event/Condition/cust_status.pm
+++ b/FS/FS/part_event/Condition/cust_status.pm
@@ -29,4 +29,12 @@ sub condition {
$hashref->{ $cust_main->status };
}
+sub condition_sql {
+ my( $self, $table ) = @_;
+
+ '('.FS::cust_main->cust_status_sql . ') IN '.
+ $self->condition_sql_option_option('status');
+}
+
+
1;
diff --git a/FS/FS/part_event/Condition/has_referral_custnum.pm b/FS/FS/part_event/Condition/has_referral_custnum.pm
index 61a8155..70c9c7f 100644
--- a/FS/FS/part_event/Condition/has_referral_custnum.pm
+++ b/FS/FS/part_event/Condition/has_referral_custnum.pm
@@ -38,11 +38,13 @@ sub condition {
}
sub condition_sql {
- #my( $class, $table ) = @_;
+ my( $class, $table ) = @_;
- "cust_main.referral_custnum IS NOT NULL";
-
- #XXX a bit harder to check active status here
+ my $sql = FS::cust_main->active_sql;
+ $sql =~ s/cust_main.custnum/cust_main.referral_custnum/;
+ $sql = 'cust_main.referral_custnum IS NOT NULL AND ('.
+ $class->condition_sql_option('active') . ' IS NULL OR '.$sql.')';
+ return $sql;
}
1;
diff --git a/FS/FS/part_event/Condition/once_every.pm b/FS/FS/part_event/Condition/once_every.pm
new file mode 100644
index 0000000..2921b3a
--- /dev/null
+++ b/FS/FS/part_event/Condition/once_every.pm
@@ -0,0 +1,46 @@
+package FS::part_event::Condition::once_every;
+
+use strict;
+use FS::Record qw( qsearch );
+use FS::part_event;
+use FS::cust_event;
+
+use base qw( FS::part_event::Condition );
+
+sub description { "Don't run this event more than once in the specified interval"; }
+
+# Runs the event at most "once every X".
+
+sub option_fields {
+ (
+ 'run_delay' => { label=>'Interval', type=>'freq', value=>'1m', },
+ );
+}
+
+sub condition {
+ my($self, $object, %opt) = @_;
+
+ my $obj_pkey = $object->primary_key;
+ my $tablenum = $object->$obj_pkey();
+
+ my $max_date = $self->option_age_from('run_delay',$opt{'time'});
+
+ my @existing = qsearch( {
+ 'table' => 'cust_event',
+ 'hashref' => {
+ 'eventpart' => $self->eventpart,
+ 'tablenum' => $tablenum,
+ 'status' => { op=>'!=', value=>'failed' },
+ '_date' => { op=>'>=', value=>$max_date },
+ },
+ 'extra_sql' => ( $opt{'cust_event'}->eventnum =~ /^(\d+)$/
+ ? " AND eventnum != $1 "
+ : ''
+ ),
+ } );
+
+ ! scalar(@existing);
+
+}
+
+1;
diff --git a/FS/FS/part_event/Condition/once_perinv.pm b/FS/FS/part_event/Condition/once_perinv.pm
new file mode 100644
index 0000000..f85a056
--- /dev/null
+++ b/FS/FS/part_event/Condition/once_perinv.pm
@@ -0,0 +1,57 @@
+package FS::part_event::Condition::once_perinv;
+
+use strict;
+use FS::Record qw( qsearch );
+use FS::part_event;
+use FS::cust_event;
+
+use base qw( FS::part_event::Condition );
+
+sub description { "Run only once for each time the package has been billed"; }
+
+# Run the event, at most, a number of times equal to the number of
+# distinct invoices that contain line items from this package.
+
+sub eventtable_hashref {
+ { 'cust_main' => 0,
+ 'cust_bill' => 0,
+ 'cust_pkg' => 1,
+ };
+}
+
+sub condition {
+ my($self, $cust_pkg, %opt) = @_;
+
+ my %invnum;
+ $invnum{$_->invnum} = 1
+ foreach ( qsearch('cust_bill_pkg', { 'pkgnum' => $cust_pkg->pkgnum }) );
+ my @events = qsearch( {
+ 'table' => 'cust_event',
+ 'hashref' => { 'eventpart' => $self->eventpart,
+ 'status' => { op=>'!=', value=>'failed' },
+ 'tablenum' => $cust_pkg->pkgnum,
+ },
+ 'extra_sql' => ( $opt{'cust_event'}->eventnum =~ /^(\d+)$/
+ ? " AND eventnum != $1 " : '' ),
+ } );
+ scalar(@events) < scalar(keys %invnum);
+}
+
+sub condition_sql {
+ my( $self, $table ) = @_;
+
+ "(
+ ( SELECT COUNT(distinct(invnum))
+ FROM cust_bill_pkg
+ WHERE cust_bill_pkg.pkgnum = cust_pkg.pkgnum )
+ >
+ ( SELECT COUNT(*)
+ FROM cust_event
+ WHERE cust_event.eventpart = part_event.eventpart
+ AND cust_event.tablenum = cust_pkg.pkgnum
+ AND status != 'failed' )
+ )"
+
+}
+
+1;
diff --git a/FS/FS/part_event/Condition/payby.pm b/FS/FS/part_event/Condition/payby.pm
index d931568..16bf480 100644
--- a/FS/FS/part_event/Condition/payby.pm
+++ b/FS/FS/part_event/Condition/payby.pm
@@ -30,21 +30,15 @@ sub condition {
my $cust_main = $self->cust_main($object);
- #uuh.. all right? test this.
my $hashref = $self->option('payby') || {};
$hashref->{ $cust_main->payby };
}
-#sub condition_sql {
-# my( $self, $table ) = @_;
-#
-# #uuh... yeah... something like this. test it for sure.
-#
-# my @payby = keys %{ $self->option('payby') };
-#
-# ' ( '. join(' OR ', map { "cust_main.payby = '$_'" } @payby ). ' ) ';
-#
-#}
+sub condition_sql {
+ my( $self, $table ) = @_;
+
+ 'cust_main.payby IN '. $self->condition_sql_option_option('payby');
+}
1;
diff --git a/FS/FS/part_event/Condition/pkg_age.pm b/FS/FS/part_event/Condition/pkg_age.pm
index 8b3b4c9..4a85387 100644
--- a/FS/FS/part_event/Condition/pkg_age.pm
+++ b/FS/FS/part_event/Condition/pkg_age.pm
@@ -49,10 +49,18 @@ sub condition {
}
-#XXX write me for efficiency
-#sub condition_sql {
-#
-#}
+sub condition_sql {
+ my( $class, $table, %opt ) = @_;
+ my $age = $class->condition_sql_option_age_from('age', $opt{'time'});
+ my $field = $class->condition_sql_option('field');
+#amazingly, this is actually faster
+ my $sql = '( CASE';
+ foreach( qw(setup last_bill bill adjourn susp expire cancel) ) {
+ $sql .= " WHEN $field = '$_' THEN (cust_pkg.$_ IS NOT NULL AND cust_pkg.$_ <= $age)";
+ }
+ $sql .= ' END )';
+ return $sql;
+}
1;
diff --git a/FS/FS/part_event/Condition/pkg_next_bill_within.pm b/FS/FS/part_event/Condition/pkg_next_bill_within.pm
new file mode 100644
index 0000000..90c4c6a
--- /dev/null
+++ b/FS/FS/part_event/Condition/pkg_next_bill_within.pm
@@ -0,0 +1,51 @@
+package FS::part_event::Condition::pkg_next_bill_within;
+
+use strict;
+use base qw( FS::part_event::Condition );
+use FS::Record qw( qsearch );
+
+sub description {
+ 'Next bill date within upcoming interval';
+}
+
+# Run the event when the next bill date is within X days.
+# To clarify, that's within X days _after_ the current date,
+# not before.
+# Combine this with a "once_every" condition so that the event
+# won't repeat every day until the bill date.
+
+sub eventtable_hashref {
+ { 'cust_main' => 0,
+ 'cust_bill' => 0,
+ 'cust_pkg' => 1,
+ };
+}
+
+sub option_fields {
+ (
+ 'within' => { 'label' => 'Bill date within',
+ 'type' => 'freq',
+ },
+ # possibly "field" to allow date fields besides 'bill'?
+ );
+}
+
+sub condition {
+ my( $self, $cust_pkg, %opt ) = @_;
+
+ my $pkg_date = $cust_pkg->get('bill') or return 0;
+ $pkg_date = $self->option_age_from('within', $pkg_date );
+
+ $opt{'time'} >= $pkg_date;
+
+}
+
+#XXX write me for efficiency
+sub condition_sql {
+ my ($self, $table, %opt) = @_;
+ $opt{'time'}.' >= '.
+ $self->condition_sql_option_age_from('within', 'cust_pkg.bill')
+}
+
+1;
+
diff --git a/FS/FS/part_event/Condition/pkg_recurring.pm b/FS/FS/part_event/Condition/pkg_recurring.pm
index 1b66821..1a08869 100644
--- a/FS/FS/part_event/Condition/pkg_recurring.pm
+++ b/FS/FS/part_event/Condition/pkg_recurring.pm
@@ -20,12 +20,9 @@ sub condition {
}
-
-#XXX join part_pkg USING (pkgpart)
-# part_pkg.freq != '0'
-#sub condition_sql {
-#
-#}
+sub condition_sql {
+ FS::cust_pkg->recurring_sql()
+}
1;
diff --git a/FS/FS/part_event/Condition/pkg_status.pm b/FS/FS/part_event/Condition/pkg_status.pm
index 6c1c9cc..3fb374e 100644
--- a/FS/FS/part_event/Condition/pkg_status.pm
+++ b/FS/FS/part_event/Condition/pkg_status.pm
@@ -34,4 +34,11 @@ sub condition {
$hashref->{ $cust_pkg->status };
}
+sub condition_sql {
+ my( $self, $table ) = @_;
+
+ '('.FS::cust_pkg->status_sql . ') IN '.
+ $self->condition_sql_option_option('status');
+}
+
1;
diff --git a/FS/FS/part_export/acct_http.pm b/FS/FS/part_export/acct_http.pm
new file mode 100644
index 0000000..b4c64ac
--- /dev/null
+++ b/FS/FS/part_export/acct_http.pm
@@ -0,0 +1,63 @@
+package FS::part_export::acct_http;
+
+use vars qw( @ISA %info );
+use FS::part_export::http;
+use Tie::IxHash;
+
+@ISA = qw( FS::part_export::http );
+
+tie %options, 'Tie::IxHash',
+ 'method' => { label =>'Method',
+ type =>'select',
+ #options =>[qw(POST GET)],
+ options =>[qw(POST)],
+ default =>'POST' },
+ 'url' => { label => 'URL', default => 'http://', },
+ 'insert_data' => {
+ label => 'Insert data',
+ type => 'textarea',
+ default => join("\n",
+ "action 'add'",
+ "username \$svc_x->username",
+ "password \$svc_x->_password",
+ "prismid \$cust_main->agent_custid ? \$cust_main->agent_custid : \$cust_main->custnum ",
+ "name \$cust_main->first.' '.\$cust_main->last",
+ ),
+ },
+ 'delete_data' => {
+ label => 'Delete data',
+ type => 'textarea',
+ default => join("\n",
+ "action 'remove'",
+ "username \$svc_x->username",
+ ),
+ },
+ 'replace_data' => {
+ label => 'Replace data',
+ type => 'textarea',
+ default => join("\n",
+ "action 'update'",
+ "username \$old->username",
+ "password \$new->_password",
+ ),
+ },
+ 'success_regexp' => {
+ label => 'Success Regexp',
+ default => '',
+ },
+;
+
+%info = (
+ 'svc' => 'svc_acct',
+ 'desc' => 'Send an HTTP or HTTPS GET or POST request, for accounts.',
+ 'options' => \%options,
+ 'notes' => <<'END'
+Send an HTTP or HTTPS GET or POST to the specified URL on account addition,
+modification and deletion. For HTTPS support,
+<a href="http://search.cpan.org/dist/Crypt-SSLeay">Crypt::SSLeay</a>
+or <a href="http://search.cpan.org/dist/IO-Socket-SSL">IO::Socket::SSL</a>
+is required.
+END
+);
+
+1;
diff --git a/FS/FS/part_export/communigate_pro.pm b/FS/FS/part_export/communigate_pro.pm
index 3ac0dfd..cc96086 100644
--- a/FS/FS/part_export/communigate_pro.pm
+++ b/FS/FS/part_export/communigate_pro.pm
@@ -85,10 +85,12 @@ sub _export_insert_svc_acct {
'MailToAll' =>($svc_acct->cgp_mailtoall ?'YES':'NO'),
'AddMailTrailer' =>($svc_acct->cgp_addmailtrailer ?'YES':'NO'),
+ 'ArchiveMessagesAfter' => $svc_acct->cgp_archiveafter,
+
map { $quotas{$_} => $svc_acct->$_() }
grep $svc_acct->$_(), keys %quotas
);
- #XXX phase 3: archive messages, mailing lists
+ #XXX phase 3: mailing lists
my @options = ( 'CreateAccount',
'accountName' => $self->export_username($svc_acct),
@@ -144,6 +146,15 @@ sub _export_insert_svc_acct {
warn "WARNING: error queueing SetAccountMailRules job: $rule_error"
if $rule_error;
+ my $rpop_error = $self->communigate_pro_queue(
+ $svc_acct->svcnum,
+ 'SetAccountRPOPs',
+ $self->export_username($svc_acct),
+ $svc_acct->cgp_rpop_hashref,
+ );
+ warn "WARNING: error queueing SetAccountMailRPOPs job: $rpop_error"
+ if $rpop_error;
+
'';
}
@@ -194,6 +205,7 @@ sub _export_insert_svc_domain {
'RPOPAllowed' =>($svc_domain->acct_def_cgp_rpopallowed ?'YES':'NO'),
'MailToAll' =>($svc_domain->acct_def_cgp_mailtoall ?'YES':'NO'),
'AddMailTrailer' =>($svc_domain->acct_def_cgp_addmailtrailer ?'YES':'NO'),
+ 'ArchiveMessagesAfter' => $svc_domain->acct_def_cgp_archiveafter,
);
warn "WARNING: error queueing SetAccountDefaults job: $def_err"
if $def_err;
@@ -318,8 +330,10 @@ sub _export_replace_svc_acct {
if $old->cgp_mailtoall ne $new->cgp_mailtoall;
$settings{'AddMailTrailer'} = ( $new->cgp_addmailtrailer ? 'YES':'NO' )
if $old->cgp_addmailtrailer ne $new->cgp_addmailtrailer;
+ $settings{'ArchiveMessagesAfter'} = $new->cgp_archiveafter
+ if $old->cgp_archiveafter ne $new->cgp_archiveafter;
- #XXX phase 3: archive messages, mailing lists
+ #XXX phase 3: mailing lists
if ( keys %settings ) {
my $error = $self->communigate_pro_queue(
@@ -376,6 +390,15 @@ sub _export_replace_svc_acct {
warn "WARNING: error queueing SetAccountMailRules job: $rule_error"
if $rule_error;
+ my $rpop_error = $self->communigate_pro_queue(
+ $new->svcnum,
+ 'SetAccountRPOPs',
+ $self->export_username($new),
+ $new->cgp_rpop_hashref,
+ );
+ warn "WARNING: error queueing SetAccountMailRPOPs job: $rpop_error"
+ if $rpop_error;
+
'';
}
@@ -441,6 +464,7 @@ sub _export_replace_svc_domain {
'RPOPAllowed' => ( $new->acct_def_cgp_rpopallowed ? 'YES' : 'NO' ),
'MailToAll' => ( $new->acct_def_cgp_mailtoall ? 'YES' : 'NO' ),
'AddMailTrailer' => ( $new->acct_def_cgp_addmailtrailer ? 'YES' : 'NO' ),
+ 'ArchiveMessagesAfter' => $new->acct_def_cgp_archiveafter,
);
warn "WARNING: error queueing SetAccountDefaults job: $def_err"
if $def_err;
@@ -730,13 +754,7 @@ sub export_getsettings_svc_domain {
foreach my $key ( grep ref($effective_settings->{$_}),
keys %$effective_settings )
{
- my $value = $effective_settings->{$key};
- if ( ref($value) eq 'ARRAY' ) {
- $effective_settings->{$key} = join(' ', @$value);
- } else {
- #XXX
- warn "serializing ". ref($value). " for table display not yet handled";
- }
+ $effective_settings->{$key} = _pretty( $effective_settings->{$key} );
}
%{$settingsref} = %$effective_settings;
@@ -801,6 +819,20 @@ sub export_getsettings_svc_acct {
map _rule2string($_), @$rules
);
+# #rpops too
+# my $rpops = eval { $self->communigate_pro_runcommand(
+# 'GetAccountRPOPs',
+# $svc_acct->email
+# ) };
+# return $@ if $@;
+#
+# %$effective_settings = ( %$effective_settings,
+# map _rpop2string($_), %$rpops
+# );
+# %$settings = ( %$settings,
+# map _rpop2string($_), %rpops
+# );
+
#aliases too
my $aliases = eval { $self->communigate_pro_runcommand(
'GetAccountAliases',
@@ -819,13 +851,7 @@ sub export_getsettings_svc_acct {
foreach my $key ( grep ref($effective_settings->{$_}),
keys %$effective_settings )
{
- my $value = $effective_settings->{$key};
- if ( ref($value) eq 'ARRAY' ) {
- $effective_settings->{$key} = join(' ', @$value);
- } else {
- #XXX
- warn "serializing ". ref($value). " for table display not yet handled";
- }
+ $effective_settings->{$key} = _pretty( $effective_settings->{$key} );
}
%{$settingsref} = %$effective_settings;
@@ -835,6 +861,22 @@ sub export_getsettings_svc_acct {
}
+sub _pretty {
+ my $value = shift;
+ if ( ref($value) eq 'ARRAY' ) {
+ '['. join(' ', map { ref($_) ? _pretty($_) : $_ } @$value ). ']';
+ } elsif ( ref($value) eq 'HASH' ) {
+ '{'. join(', ',
+ map { my $v = $value->{$_};
+ "$_:". ( ref($v) ? _pretty($v) : $v );
+ }
+ keys %$value
+ ). '}';
+ } else {
+ warn "serializing ". ref($value). " for table display not yet handled";
+ }
+}
+
sub export_getsettings_svc_forward {
my($self, $svc_forward, $settingsref, $defaultref ) = @_;
@@ -860,6 +902,14 @@ sub _rule2string {
("Mail rule $name" => "$priority IF $conditions THEN $actions ($comment)");
}
+#sub _rpop2string {
+# my $rpop = shift;
+# my($priority, $name, $conditions, $actions, $comment) = @$rule;
+# $conditions = join(', ', map { my $a = $_; join(' ', @$a); } @$conditions);
+# $actions = join(', ', map { my $a = $_; join(' ', @$a); } @$actions);
+# ("Mail rule $name" => "$priority IF $conditions THEN $actions ($comment)");
+#}
+
sub export_getsettings_svc_mailinglist {
my($self, $svc_mailinglist, $settingsref, $defaultref ) = @_;
@@ -897,6 +947,7 @@ sub communigate_pro_queue_dep {
'UpdateAccountDefaults' => 'cp_Scalar_settingsHash',
'SetAccountDefaultPrefs' => 'cp_Scalar_settingsHash',
'UpdateAccountDefaultPrefs' => 'cp_Scalar_settingsHash',
+ 'SetAccountRPOPs' => 'cp_Scalar_Hash',
);
my $sub = exists($kludge_methods{$method})
? $kludge_methods{$method}
diff --git a/FS/FS/part_export/cust_http.pm b/FS/FS/part_export/cust_http.pm
index 59503e8..e8b677b 100644
--- a/FS/FS/part_export/cust_http.pm
+++ b/FS/FS/part_export/cust_http.pm
@@ -6,28 +6,50 @@ use Tie::IxHash;
@ISA = qw( FS::part_export::http );
-tie my %options, 'Tie::IxHash', %FS::part_export::http::options;
-
-$options{'insert_data'}->{'default'} = join("\n",
- "action 'insert'",
- "custnum \$cust_main->custnum",
- "first \$cust_main->first",
- "last \$cust_main->get('last')",
- ( map "$_ \$cust_main->$_", qw( company address1 address2 city county state zip country daytime night fax last ) ),
- "email \$cust_main->invoicing_list_emailonly_scalar",
-);
-$options{'delete_data'}->{'default'} = join("\n",
- "action 'delete'",
- "custnum \$cust_main->custnum",
-);
-$options{'replace_data'}->{'default'} = join("\n",
- "action 'replace'",
- "custnum \$new_cust_main->custnum",
- "first \$new_cust_main->first",
- "last \$new_cust_main->get('last')",
- ( map "$_ \$cust_main->$_", qw( company address1 address2 city county state zip country daytime night fax last ) ),
- "email \$new_cust_main->invoicing_list_emailonly_scalar",
-);
+tie %options, 'Tie::IxHash',
+ 'method' => { label =>'Method',
+ type =>'select',
+ #options =>[qw(POST GET)],
+ options =>[qw(POST)],
+ default =>'POST' },
+ 'url' => { label => 'URL', default => 'http://', },
+ 'insert_data' => {
+ label => 'Insert data',
+ type => 'textarea',
+ default => join("\n",
+ "action 'insert'",
+ "custnum \$cust_main->custnum",
+ "first \$cust_main->first",
+ "last \$cust_main->get('last')",
+ ( map "$_ \$cust_main->$_", qw( company address1 address2 city county state zip country daytime night fax last ) ),
+ "email \$cust_main->invoicing_list_emailonly_scalar",
+ ),
+ },
+ 'delete_data' => {
+ label => 'Delete data',
+ type => 'textarea',
+ default => join("\n",
+ "action 'delete'",
+ "custnum \$cust_main->custnum",
+ ),
+ },
+ 'replace_data' => {
+ label => 'Replace data',
+ type => 'textarea',
+ default => join("\n",
+ "action 'replace'",
+ "custnum \$new_cust_main->custnum",
+ "first \$new_cust_main->first",
+ "last \$new_cust_main->get('last')",
+ ( map "$_ \$cust_main->$_", qw( company address1 address2 city county state zip country daytime night fax last ) ),
+ "email \$new_cust_main->invoicing_list_emailonly_scalar",
+ ),
+ },
+ 'success_regexp' => {
+ label => 'Success Regexp',
+ default => '',
+ },
+;
%info = (
'svc' => 'cust_main',
diff --git a/FS/FS/part_export/domain_sql.pm b/FS/FS/part_export/domain_sql.pm
index 0ce1b16..3010338 100644
--- a/FS/FS/part_export/domain_sql.pm
+++ b/FS/FS/part_export/domain_sql.pm
@@ -99,6 +99,7 @@ sub _export_replace {
my %schema = $self->_schema_map;
my %static = $self->_static_map;
+ #my %map = (%schema, %static);
my @primary_key = ();
if ( $self->option('primary_key') =~ /,/ ) {
@@ -107,6 +108,7 @@ sub _export_replace {
push @primary_key, $old->$keymap();
}
} else {
+ my %map = (%schema, %static);
my $keymap = $map{$self->option('primary_key')};
push @primary_key, $old->$keymap();
}
@@ -135,6 +137,7 @@ sub _export_delete {
my %schema = $self->_schema_map;
my %static = $self->_static_map;
+ my %map = (%schema, %static);
my %primary_key = ();
if ( $self->option('primary_key') =~ /,/ ) {
diff --git a/FS/FS/part_export/domreg_opensrs.pm b/FS/FS/part_export/domreg_opensrs.pm
index 6554991..76f0059 100644
--- a/FS/FS/part_export/domreg_opensrs.pm
+++ b/FS/FS/part_export/domreg_opensrs.pm
@@ -379,6 +379,8 @@ Like most export functions, returns an error message on failure or undef on succ
sub register {
my ( $self, $svc_domain, $years ) = @_;
+ $years = 1 unless $years; #default to 1 year since we don't seem to pass it
+
return "Net::OpenSRS does not support period other than 1 year" if $years != 1;
eval "use Net::OpenSRS;";
diff --git a/FS/FS/part_export/http.pm b/FS/FS/part_export/http.pm
index e5e5a5c..3749224 100644
--- a/FS/FS/part_export/http.pm
+++ b/FS/FS/part_export/http.pm
@@ -33,6 +33,10 @@ tie %options, 'Tie::IxHash',
default => join("\n",
),
},
+ 'success_regexp' => {
+ label => 'Success Regexp',
+ default => '',
+ },
;
%info = (
@@ -71,6 +75,7 @@ sub _export_command {
$self->http_queue( $svc_x->svcnum,
$self->option('method'),
$self->option('url'),
+ $self->option('success_regexp'),
map {
/^\s*(\S+)\s+(.*)$/ or /()()/;
my( $field, $value_expression ) = ( $1, $2 );
@@ -95,6 +100,7 @@ sub _export_replace {
$self->http_queue( $new->svcnum,
$self->option('method'),
$self->option('url'),
+ $self->option('success_regexp'),
map {
/^\s*(\S+)\s+(.*)$/ or /()()/;
my( $field, $value_expression ) = ( $1, $2 );
@@ -114,7 +120,7 @@ sub http_queue {
}
sub http {
- my($method, $url, @data) = @_;
+ my($method, $url, $success_regexp, @data) = @_;
$method = lc($method);
@@ -134,6 +140,11 @@ sub http {
die $response->error_as_HTML if $response->is_error;
+ if(length($success_regexp) > 1) {
+ my $response_content = $response->content;
+ die $response_content unless $response_content =~ /$success_regexp/;
+ }
+
}
1;
diff --git a/FS/FS/part_export/rt_ticket.pm b/FS/FS/part_export/rt_ticket.pm
new file mode 100644
index 0000000..13a0367
--- /dev/null
+++ b/FS/FS/part_export/rt_ticket.pm
@@ -0,0 +1,214 @@
+package FS::part_export::rt_ticket;
+
+use vars qw(@ISA %info);
+use Tie::IxHash;
+use FS::part_export;
+use FS::Record qw(qsearch qsearchs);
+use FS::Conf;
+use FS::TicketSystem;
+
+@ISA = qw(FS::part_export);
+
+my %templates;
+my %queues;
+my %template_select = (
+ type => 'select',
+ freeform => 1,
+ option_label => sub {
+ $templates{$_[0]};
+ },
+ option_values => sub {
+ %templates = (0 => '',
+ map { $_->msgnum, $_->msgname }
+ qsearch({ table => 'msg_template',
+ hashref => {},
+ order_by => 'ORDER BY msgnum ASC'
+ })
+ );
+ sort keys (%templates);
+ },
+);
+
+my %queue_select = (
+ type => 'select',
+ freeform => 1,
+ option_label => sub {
+ $queues{$_[0]};
+ },
+ option_values => sub {
+ %queues = (0 => '', FS::TicketSystem->queues());
+ sort {$queues{$a} cmp $queues{$b}} keys %queues;
+ },
+);
+
+tie my %options, 'Tie::IxHash', (
+ 'insert_queue' => {
+ before => '
+<TR><TD COLSPAN=2>
+<TABLE>
+ <TR><TH></TH><TH>Queue</TH><TH>Template</TH></TR>
+ <TR><TD>New service</TD><TD>',
+ %queue_select,
+ after => '</TD>'
+ },
+ 'insert_template' => {
+ before => '<TD>',
+ %template_select,
+ after => '</TD></TR>
+',
+ },
+ 'delete_queue' => {
+ before => '
+ <TR><TD>Delete</TD><TD>',
+ %queue_select,
+ after => '</TD>',
+ },
+ 'delete_template' => {
+ before => '<TD>',
+ %template_select,
+ after => '</TD></TR>
+',
+ },
+ 'replace_queue' => {
+ before => '
+ <TR><TD>Modify</TD><TD>',
+ %queue_select,
+ after => '</TD>',
+ },
+ 'replace_template' => {
+ before => '<TD>',
+ %template_select,
+ after => '</TD></TR>
+',
+ },
+ 'suspend_queue' => {
+ before => '
+ <TR><TD>Suspend</TD><TD>',
+ %queue_select,
+ after => '</TD>',
+ },
+ 'suspend_template' => {
+ before => '<TD>',
+ %template_select,
+ after => '</TD></TR>
+',
+ },
+ 'unsuspend_queue' => {
+ before => '
+ <TR><TD>Unsuspend</TD><TD>',
+ %queue_select,
+ after => '</TD>',
+ },
+ 'unsuspend_template' => {
+ before => '<TD>',
+ %template_select,
+ after => '</TD></TR>
+ </TABLE>
+</TD></TR>',
+ },
+ 'requestor' => {
+ freeform => 0,
+ label => 'Requestor',
+ 'type' => 'select',
+ option_label => sub {
+ my @labels = (
+ 'Template From: address',
+ 'Customer\'s invoice address',
+ );
+ $labels[shift];
+ },
+ option_values => sub { (0, 1) },
+ },
+);
+
+%info = (
+ 'svc' => [qw( svc_acct )], #others?
+ 'desc' =>
+ 'Create an RT ticket',
+ 'options' => \%options,
+ 'nodomain' => '',
+ 'notes' => <<'END'
+Create a ticket in RT. The subject and body of the ticket
+will be generated from a message template.
+END
+);
+
+sub _export_ticket {
+ my( $self, $action, $svc ) = (shift, shift, shift);
+ my $msgnum = $self->option($action.'_template');
+ return if !$msgnum;
+
+ my $queue = $self->option($action.'_queue');
+ return if !$queue;
+
+ my $msg_template = FS::msg_template->by_key($msgnum);
+ return "Template $msgnum not found\n" if !$msg_template;
+
+ my $cust_pkg = $svc->cust_svc->cust_pkg;
+ my $cust_main = $svc->cust_svc->cust_pkg->cust_main if $cust_pkg;
+ my $custnum = $cust_main->custnum if $cust_main;
+ my $svcnum = $svc->svcnum if $action ne 'delete';
+
+ my %msg;
+ if ( $action eq 'replace' ) {
+ my $old = shift;
+ %msg = $msg_template->prepare(
+ 'cust_main' => $cust_main,
+ 'object' => [ $svc, $old ],
+ );
+
+ }
+ else {
+ %msg = $msg_template->prepare(
+ 'cust_main' => $cust_main,
+ 'object' => $svc,
+ );
+ }
+ my $requestor = $msg{'from'};
+ $requestor = [ $cust_main->invoicing_list_emailonly ]
+ if $cust_main and $self->option('requestor') == 1;
+
+ my $err_or_ticket = FS::TicketSystem->create_ticket(
+ '', #session should already exist
+ 'queue' => $queue,
+ 'subject' => $msg{'subject'},
+ 'requestor' => $requestor,
+ 'message' => $msg{'html_body'},
+ 'mime_type' => 'text/html',
+ 'custnum' => $custnum,
+ 'svcnum' => $svcnum,
+ );
+ if( ref($err_or_ticket) ) {
+ return '';
+ }
+ else {
+ return $err_or_ticket;
+ }
+}
+
+sub _export_insert {
+ my($self, $svc) = (shift, shift);
+ $self->_export_ticket('insert', $svc);
+}
+
+sub _export_replace {
+ my($self, $new, $old) = (shift, shift, shift);
+ $self->_export_ticket('replace', $new, $old);
+}
+
+sub _export_delete {
+ my($self, $svc) = (shift, shift);
+ $self->_export_ticket('delete', $svc);
+}
+
+sub _export_suspend {
+ my($self, $svc) = (shift, shift);
+ $self->_export_ticket('suspend', $svc);
+}
+
+sub _export_unsuspend {
+ my($self, $svc) = (shift, shift);
+ $self->_export_ticket('unsuspend', $svc);
+}
+
+1;
diff --git a/FS/FS/part_export/shellcommands.pm b/FS/FS/part_export/shellcommands.pm
index ec861d3..50af45d 100644
--- a/FS/FS/part_export/shellcommands.pm
+++ b/FS/FS/part_export/shellcommands.pm
@@ -193,7 +193,7 @@ old_ for replace operations):
<LI><code>$pkgnum</code>
<LI><code>$custnum</code>
<LI>All other fields in <b>svc_acct</b> are also available.
- <LI>The following fields from <b>cust_main</b> are also available (except during replace): company, address1, address2, city, state, zip, county, daytime, night, fax, otaker. When used on the command line (rather than STDIN), they will be quoted for the shell already (do not add additional quotes).
+ <LI>The following fields from <b>cust_main</b> are also available (except during replace): company, address1, address2, city, state, zip, county, daytime, night, fax, otaker, agent_custid. When used on the command line (rather than STDIN), they will be quoted for the shell already (do not add additional quotes).
</UL>
END
);
@@ -263,7 +263,7 @@ sub _export_command {
{
no strict 'refs';
foreach my $custf (qw( company address1 address2 city state zip country
- daytime night fax otaker
+ daytime night fax otaker agent_custid
))
{
${$custf} = $cust_pkg->cust_main->$custf();
@@ -342,6 +342,7 @@ sub _export_command {
$night = shell_quote $night;
$fax = shell_quote $fax;
$otaker = shell_quote $otaker;
+ $agent_custid = shell_quote $agent_custid;
my $command_string = eval(qq("$command"));
my @ssh_cmd_args = (
@@ -375,6 +376,8 @@ sub _export_replace {
}
my $old_cust_pkg = $old->cust_svc->cust_pkg;
my $new_cust_pkg = $new->cust_svc->cust_pkg;
+ my $new_cust_main = $new_cust_pkg ? $new_cust_pkg->cust_main : '';
+
$new_finger =~ /^(.*)\s+(\S+)$/ or $new_finger =~ /^((.*))$/;
($new_first, $new_last ) = ( $1, $2 );
$quoted_new__password = shell_quote $new__password; #old, wrong?
@@ -415,6 +418,12 @@ sub _export_replace {
return $error. ' ('. $self->exporttype. ' to '. $self->machine. ')'
if $error;
+ $new_agent_custid = $new_cust_main ? $new_cust_main->agent_custid : '';
+ $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
+ $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
+ $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
+ $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
+
my $stdin_string = eval(qq("$stdin"));
$new_first = shell_quote $new_first;
@@ -422,10 +431,7 @@ sub _export_replace {
$new_finger = shell_quote $new_finger;
$new_crypt_password = shell_quote $new_crypt_password;
$new_ldap_password = shell_quote $new_ldap_password;
- $old_pkgnum = $old_cust_pkg ? $old_cust_pkg->pkgnum : '';
- $old_custnum = $old_cust_pkg ? $old_cust_pkg->custnum : '';
- $new_pkgnum = $new_cust_pkg ? $new_cust_pkg->pkgnum : '';
- $new_custnum = $new_cust_pkg ? $new_cust_pkg->custnum : '';
+ $new_agent_custid = shell_quote $new_agent_custid;
my $command_string = eval(qq("$command"));
diff --git a/FS/FS/part_export/sqlradius.pm b/FS/FS/part_export/sqlradius.pm
index 4f67ac6..15aa986 100644
--- a/FS/FS/part_export/sqlradius.pm
+++ b/FS/FS/part_export/sqlradius.pm
@@ -18,10 +18,23 @@ tie %options, 'Tie::IxHash',
'datasrc' => { label=>'DBI data source ' },
'username' => { label=>'Database username' },
'password' => { label=>'Database password' },
+ 'usergroup' => { label => 'Group table',
+ type => 'select',
+ options => [qw( usergroup radusergroup ) ],
+ },
'ignore_accounting' => {
type => 'checkbox',
label => 'Ignore accounting records from this database'
},
+ 'process_single_realm' => {
+ type => 'checkbox',
+ label => 'Only process one realm of accounting records',
+ },
+ 'realm' => { label => 'The realm of of accounting records to be processed' },
+ 'ignore_long_sessions' => {
+ type => 'checkbox',
+ label => 'Ignore sessions which span billing periods',
+ },
'hide_ip' => {
type => 'checkbox',
label => 'Hide IP address information on session reports',
@@ -43,7 +56,7 @@ tie %options, 'Tie::IxHash',
;
$notes1 = <<'END';
-Real-time export of <b>radcheck</b>, <b>radreply</b> and <b>usergroup</b>
+Real-time export of <b>radcheck</b>, <b>radreply</b> and <b>usergroup</b>/<b>radusergroup</b>
tables to any SQL database for
<a href="http://www.freeradius.org/">FreeRADIUS</a>
or <a href="http://radius.innercite.com/">ICRADIUS</a>.
@@ -112,9 +125,10 @@ sub _export_insert {
cluck localtime(). ": queuing usergroup_insert for ". $svc_x->svcnum.
" (". $self->export_username($svc_x). " with ". join(", ", @groups)
if $DEBUG;
+ my $usergroup = $self->option('usergroup') || 'usergroup';
my $err_or_queue = $self->sqlradius_queue(
$svc_x->svcnum, 'usergroup_insert',
- $self->export_username($svc_x), @groups );
+ $self->export_username($svc_x), $usergroup, @groups );
return $err_or_queue unless ref($err_or_queue);
}
'';
@@ -136,8 +150,9 @@ sub _export_replace {
my $jobnum = '';
if ( $self->export_username($old) ne $self->export_username($new) ) {
+ my $usergroup = $self->option('usergroup') || 'usergroup';
my $err_or_queue = $self->sqlradius_queue( $new->svcnum, 'rename',
- $self->export_username($new), $self->export_username($old) );
+ $self->export_username($new), $self->export_username($old), $usergroup );
unless ( ref($err_or_queue) ) {
$dbh->rollback if $oldAutoCommit;
return $err_or_queue;
@@ -286,8 +301,9 @@ sub _export_unsuspend {
sub _export_delete {
my( $self, $svc_x ) = (shift, shift);
+ my $usergroup = $self->option('usergroup') || 'usergroup';
my $err_or_queue = $self->sqlradius_queue( $svc_x->svcnum, 'delete',
- $self->export_username($svc_x) );
+ $self->export_username($svc_x), $usergroup );
ref($err_or_queue) ? '' : $err_or_queue;
}
@@ -378,14 +394,16 @@ sub sqlradius_insert { #subroutine, not method
sub sqlradius_usergroup_insert { #subroutine, not method
my $dbh = sqlradius_connect(shift, shift, shift);
- my( $username, @groups ) = @_;
+ my $username = shift;
+ my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
+ my @groups = @_;
my $s_sth = $dbh->prepare(
- "SELECT COUNT(*) FROM usergroup WHERE UserName = ? AND GroupName = ?"
+ "SELECT COUNT(*) FROM $usergroup WHERE UserName = ? AND GroupName = ?"
) or die $dbh->errstr;
my $sth = $dbh->prepare(
- "INSERT INTO usergroup ( UserName, GroupName ) VALUES ( ?, ? )"
+ "INSERT INTO $usergroup ( UserName, GroupName ) VALUES ( ?, ? )"
) or die $dbh->errstr;
foreach my $group ( @groups ) {
@@ -399,15 +417,25 @@ sub sqlradius_usergroup_insert { #subroutine, not method
$sth->execute( $username, $group )
or die "can't insert into groupname table: ". $sth->errstr;
}
+ if ( $s_sth->{Active} ) {
+ warn "sqlradius s_sth still active; calling ->finish()";
+ $s_sth->finish;
+ }
+ if ( $sth->{Active} ) {
+ warn "sqlradius sth still active; calling ->finish()";
+ $sth->finish;
+ }
$dbh->disconnect;
}
sub sqlradius_usergroup_delete { #subroutine, not method
my $dbh = sqlradius_connect(shift, shift, shift);
- my( $username, @groups ) = @_;
+ my $username = shift;
+ my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
+ my @groups = @_;
my $sth = $dbh->prepare(
- "DELETE FROM usergroup WHERE UserName = ? AND GroupName = ?"
+ "DELETE FROM $usergroup WHERE UserName = ? AND GroupName = ?"
) or die $dbh->errstr;
foreach my $group ( @groups ) {
$sth->execute( $username, $group )
@@ -418,8 +446,9 @@ sub sqlradius_usergroup_delete { #subroutine, not method
sub sqlradius_rename { #subroutine, not method
my $dbh = sqlradius_connect(shift, shift, shift);
- my($new_username, $old_username) = @_;
- foreach my $table (qw(radreply radcheck usergroup )) {
+ my($new_username, $old_username) = (shift, shift);
+ my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
+ foreach my $table (qw(radreply radcheck), $usergroup ) {
my $sth = $dbh->prepare("UPDATE $table SET Username = ? WHERE UserName = ?")
or die $dbh->errstr;
$sth->execute($new_username, $old_username)
@@ -445,8 +474,9 @@ sub sqlradius_attrib_delete { #subroutine, not method
sub sqlradius_delete { #subroutine, not method
my $dbh = sqlradius_connect(shift, shift, shift);
my $username = shift;
+ my $usergroup = ( $_[0] =~ /^(rad)?usergroup/i ) ? shift : 'usergroup';
- foreach my $table (qw( radcheck radreply usergroup )) {
+ foreach my $table (qw( radcheck radreply), $usergroup ) {
my $sth = $dbh->prepare( "DELETE FROM $table WHERE UserName = ?" );
$sth->execute($username)
or die "can't delete from $table table: ". $sth->errstr;
@@ -475,9 +505,11 @@ sub sqlreplace_usergroups {
push @delgroups, $oldgroup;
}
+ my $usergroup = $self->option('usergroup') || 'usergroup';
+
if ( @delgroups ) {
my $err_or_queue = $self->sqlradius_queue( $svcnum, 'usergroup_delete',
- $username, @delgroups );
+ $username, $usergroup, @delgroups );
return $err_or_queue
unless ref($err_or_queue);
if ( $jobnum ) {
@@ -491,7 +523,7 @@ sub sqlreplace_usergroups {
"with ". join(", ", @newgroups)
if $DEBUG;
my $err_or_queue = $self->sqlradius_queue( $svcnum, 'usergroup_insert',
- $username, @newgroups );
+ $username, $usergroup, @newgroups );
return $err_or_queue
unless ref($err_or_queue);
if ( $jobnum ) {
@@ -617,7 +649,7 @@ sub usage_sessions {
if ( $svc_acct ) {
my $username = $self->export_username($svc_acct);
- if ( $svc_acct =~ /^([^@]+)\@([^@]+)$/ ) {
+ if ( $username =~ /^([^@]+)\@([^@]+)$/ ) {
push @where, '( UserName = ? OR ( UserName = ? AND Realm = ? ) )';
push @param, $username, $1, $2;
} else {
@@ -626,6 +658,11 @@ sub usage_sessions {
}
}
+ if ($self->option('process_single_realm')) {
+ push @where, 'Realm = ?';
+ push @param, $self->option('realm');
+ }
+
if ( length($ip) ) {
push @where, ' FramedIPAddress = ?';
push @param, $ip;
@@ -719,43 +756,53 @@ sub update_svc {
my $oldAutoCommit = $FS::UID::AutoCommit; # can't undo side effects, but at
local $FS::UID::AutoCommit = 0; # least we can avoid over counting
- my @svc_acct =
- grep { qsearch( 'export_svc', { 'exportnum' => $self->exportnum,
- 'svcpart' => $_->cust_svc->svcpart, } )
- }
- qsearch( 'svc_acct',
- { 'username' => $UserName },
- '',
- $extra_sql
- );
-
+ my $status = 'skipped';
my $errinfo = "for RADIUS detail RadAcctID $RadAcctId ".
"(UserName $UserName, Realm $Realm)";
- my $status = 'skipped';
- if ( !@svc_acct ) {
- warn "WARNING: no svc_acct record found $errinfo - skipping\n";
- } elsif ( scalar(@svc_acct) > 1 ) {
- warn "WARNING: multiple svc_acct records found $errinfo - skipping\n";
- } else {
- my $svc_acct = $svc_acct[0];
- warn "found svc_acct ". $svc_acct->svcnum. " $errinfo\n" if $DEBUG;
-
- $svc_acct->last_login($AcctStartTime);
- $svc_acct->last_logout($AcctStopTime);
-
- my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
- if ( $cust_pkg && $AcctStopTime < ( $cust_pkg->last_bill
- || $cust_pkg->setup ) ) {
- $status = 'skipped (too old)';
+ if ( $self->option('process_single_realm')
+ && $self->option('realm') ne $Realm )
+ {
+ warn "WARNING: wrong realm $errinfo - skipping\n" if $DEBUG;
+ } else {
+ my @svc_acct =
+ grep { qsearch( 'export_svc', { 'exportnum' => $self->exportnum,
+ 'svcpart' => $_->cust_svc->svcpart, } )
+ }
+ qsearch( 'svc_acct',
+ { 'username' => $UserName },
+ '',
+ $extra_sql
+ );
+
+ if ( !@svc_acct ) {
+ warn "WARNING: no svc_acct record found $errinfo - skipping\n";
+ } elsif ( scalar(@svc_acct) > 1 ) {
+ warn "WARNING: multiple svc_acct records found $errinfo - skipping\n";
} else {
- my @st;
- push @st, _try_decrement($svc_acct, 'seconds', $AcctSessionTime );
- push @st, _try_decrement($svc_acct, 'upbytes', $AcctInputOctets );
- push @st, _try_decrement($svc_acct, 'downbytes', $AcctOutputOctets );
- push @st, _try_decrement($svc_acct, 'totalbytes', $AcctInputOctets
+
+ my $svc_acct = $svc_acct[0];
+ warn "found svc_acct ". $svc_acct->svcnum. " $errinfo\n" if $DEBUG;
+
+ $svc_acct->last_login($AcctStartTime);
+ $svc_acct->last_logout($AcctStopTime);
+
+ my $session_time = $AcctStopTime;
+ $session_time = $AcctStartTime if $self->option('ignore_long_sessions');
+
+ my $cust_pkg = $svc_acct->cust_svc->cust_pkg;
+ if ( $cust_pkg && $session_time < ( $cust_pkg->last_bill
+ || $cust_pkg->setup ) ) {
+ $status = 'skipped (too old)';
+ } else {
+ my @st;
+ push @st, _try_decrement($svc_acct, 'seconds', $AcctSessionTime);
+ push @st, _try_decrement($svc_acct, 'upbytes', $AcctInputOctets);
+ push @st, _try_decrement($svc_acct, 'downbytes', $AcctOutputOctets);
+ push @st, _try_decrement($svc_acct, 'totalbytes', $AcctInputOctets
+ $AcctOutputOctets);
- $status=join(' ', @st);
+ $status=join(' ', @st);
+ }
}
}
diff --git a/FS/FS/part_pkg.pm b/FS/FS/part_pkg.pm
index f278d5e..a073cf2 100644
--- a/FS/FS/part_pkg.pm
+++ b/FS/FS/part_pkg.pm
@@ -16,9 +16,11 @@ use FS::type_pkgs;
use FS::part_pkg_option;
use FS::pkg_class;
use FS::agent;
+use FS::part_pkg_taxrate;
use FS::part_pkg_taxoverride;
use FS::part_pkg_taxproduct;
use FS::part_pkg_link;
+use FS::part_pkg_discount;
@ISA = qw( FS::m2m_Common FS::option_Common );
$DEBUG = 0;
@@ -809,32 +811,34 @@ sub freq_pretty {
}
}
-=item add_freq TIMESTAMP
+=item add_freq TIMESTAMP [ FREQ ]
-Adds the frequency of this package to the provided timestamp and returns
-the resulting timestamp, or -1 if the frequency of this package could not be
-parsed (shouldn't happen).
+Adds a billing period of some frequency to the provided timestamp and
+returns the resulting timestamp, or -1 if the frequency could not be
+parsed (shouldn't happen). By default, the frequency of this package
+will be used; to override this, pass a different frequency as a second
+argument.
=cut
sub add_freq {
- my( $self, $date ) = @_;
- my $freq = $self->freq;
+ my( $self, $date, $freq ) = @_;
+ $freq = $self->freq unless $freq;
#change this bit to use Date::Manip? CAREFUL with timezones (see
# mailing list archive)
my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($date) )[0,1,2,3,4,5];
- if ( $self->freq =~ /^\d+$/ ) {
- $mon += $self->freq;
+ if ( $freq =~ /^\d+$/ ) {
+ $mon += $freq;
until ( $mon < 12 ) { $mon -= 12; $year++; }
- } elsif ( $self->freq =~ /^(\d+)w$/ ) {
+ } elsif ( $freq =~ /^(\d+)w$/ ) {
my $weeks = $1;
$mday += $weeks * 7;
- } elsif ( $self->freq =~ /^(\d+)d$/ ) {
+ } elsif ( $freq =~ /^(\d+)d$/ ) {
my $days = $1;
$mday += $days;
- } elsif ( $self->freq =~ /^(\d+)h$/ ) {
+ } elsif ( $freq =~ /^(\d+)h$/ ) {
my $hours = $1;
$hour += $hours;
} else {
@@ -937,6 +941,8 @@ sub _part_pkg_link {
qsearch({ table => 'part_pkg_link',
hashref => { 'src_pkgpart' => $self->pkgpart,
'link_type' => $type,
+ #protection against infinite recursive links
+ 'dst_pkgpart' => { op=>'!=', value=> $self->pkgpart },
},
order_by => "ORDER BY hidden",
});
@@ -1123,6 +1129,18 @@ sub part_pkg_taxrate {
} );
}
+=item part_pkg_discount
+
+Returns the package to discount m2m records (see L<FS::part_pkg_discount>)
+for this package.
+
+=cut
+
+sub part_pkg_discount {
+ my $self = shift;
+ qsearch('part_pkg_discount', { 'pkgpart' => $self->pkgpart });
+}
+
=item _rebless
Reblesses the object into the FS::part_pkg::PLAN class (if available), where
diff --git a/FS/FS/part_pkg/agent.pm b/FS/FS/part_pkg/agent.pm
index e5bd163..69ecf77 100644
--- a/FS/FS/part_pkg/agent.pm
+++ b/FS/FS/part_pkg/agent.pm
@@ -38,6 +38,10 @@ $me = '[FS::part_pkg::agent]';
'cutoff_day' => { 'name' => 'Billing Day (1 - 28)',
'default' => '1',
},
+ 'add_full_period'=> { 'name' => 'When prorating first month, also bill '.
+ 'for one full period after that',
+ 'type' => 'checkbox',
+ },
'no_pkg_prorate' => { 'name' => 'Disable prorating bulk packages (charge full price for packages active only a portion of the month)',
'type' => 'checkbox',
@@ -46,7 +50,7 @@ $me = '[FS::part_pkg::agent]';
},
#'fieldorder' => [qw( setup_fee recur_fee recur_method cutoff_day ) ],
- 'fieldorder' => [qw( setup_fee recur_fee cutoff_day no_pkg_prorate ) ],
+ 'fieldorder' => [qw( setup_fee recur_fee cutoff_day add_full_period no_pkg_prorate ) ],
'weight' => 51,
diff --git a/FS/FS/part_pkg/bulk.pm b/FS/FS/part_pkg/bulk.pm
index 69fe98e..b28fd30 100644
--- a/FS/FS/part_pkg/bulk.pm
+++ b/FS/FS/part_pkg/bulk.pm
@@ -7,7 +7,7 @@ use FS::part_pkg::flat;
@ISA = qw(FS::part_pkg::flat);
-$DEBUG = 1;
+$DEBUG = 0;
$me = '[FS::part_pkg::bulk]';
%info = (
@@ -29,9 +29,17 @@ $me = '[FS::part_pkg::bulk]';
' of service at cancellation',
'type' => 'checkbox',
},
+ 'summarize_svcs'=> { 'name' => 'Show a count of services on the invoice, '.
+ 'instead of a detailed list',
+ 'type' => 'checkbox',
+ },
+ 'no_prorate' => { 'name' => 'Don\'t prorate recurring fees on services '.
+ 'active for a partial month',
+ 'type' => 'checkbox',
+ },
},
'fieldorder' => [ 'setup_fee', 'recur_fee', 'svc_setup_fee', 'svc_recur_fee',
- 'unused_credit', ],
+ 'unused_credit', 'summarize_svcs', 'no_prorate' ],
'weight' => 50,
);
@@ -50,6 +58,11 @@ sub calc_recur {
unless $$sdate > $last_bill;
my $total_svc_charge = 0;
+ my %n_setup = ();
+ my %n_recur = ();
+ my %part_svc_label = ();
+
+ my $summarize = $self->option('summarize_svcs',1);
warn "$me billing for bulk services from ". time2str('%x', $last_bill).
" to ". time2str('%x', $$sdate). "\n"
@@ -61,6 +74,7 @@ sub calc_recur {
my @label = $h_cust_svc->label_long( $$sdate, $last_bill );
die "fatal: no historical label found, wtf?" unless scalar(@label); #?
my $svc_details = $label[0]. ': '. $label[1]. ': ';
+ $part_svc_label{$h_cust_svc->svcpart} ||= $label[0];
my $svc_charge = 0;
@@ -70,14 +84,21 @@ sub calc_recur {
} elsif ( $svc_setup_fee ) {
$svc_charge += $svc_setup_fee;
$svc_details .= $money_char. sprintf('%.2f setup, ', $svc_setup_fee);
+ $n_setup{$h_cust_svc->svcpart}++;
}
my $svc_end = $h_cust_svc->date_deleted;
$svc_end = ( !$svc_end || $svc_end > $$sdate ) ? $$sdate : $svc_end;
- my $recur_charge =
- $self->option('svc_recur_fee') * ( $svc_end - $svc_start )
+ my $recur_charge;
+ if ( $self->option('no_prorate',1) ) {
+ $recur_charge = $self->option('svc_recur_fee');
+ }
+ else {
+ $recur_charge = $self->option('svc_recur_fee')
+ * ( $svc_end - $svc_start )
/ ( $$sdate - $last_bill );
+ }
$svc_details .= $money_char. sprintf('%.2f', $recur_charge ).
' ('. time2str('%x', $svc_start).
@@ -85,11 +106,21 @@ sub calc_recur {
if $recur_charge;
$svc_charge += $recur_charge;
-
- push @$details, $svc_details;
+ $n_recur{$h_cust_svc->svcpart}++;
+ push @$details, $svc_details if !$summarize;
$total_svc_charge += $svc_charge;
}
+ if ( $summarize ) {
+ foreach my $svcpart (keys %part_svc_label) {
+ push @$details, sprintf('Setup fee: %d @ '.$money_char.'%.2f',
+ $n_setup{$svcpart}, $svc_setup_fee )
+ if $svc_setup_fee and $n_setup{$svcpart};
+ push @$details, sprintf('%d services @ '.$money_char.'%.2f',
+ $n_recur{$svcpart}, $self->option('svc_recur_fee') )
+ if $n_recur{$svcpart};
+ }
+ }
sprintf('%.2f', $self->base_recur($cust_pkg) + $total_svc_charge );
}
diff --git a/FS/FS/part_pkg/cdr_termination.pm b/FS/FS/part_pkg/cdr_termination.pm
index cfd6b4e..0666d79 100644
--- a/FS/FS/part_pkg/cdr_termination.pm
+++ b/FS/FS/part_pkg/cdr_termination.pm
@@ -58,6 +58,10 @@ tie my %temporalities, 'Tie::IxHash',
'subscription',
'default' => '1',
},
+ 'add_full_period'=> { 'name' => 'When prorating first month, also bill '.
+ 'for one full period after that',
+ 'type' => 'checkbox',
+ },
'recur_method' => { 'name' => 'Recurring fee method',
#'type' => 'radio',
@@ -90,6 +94,7 @@ tie my %temporalities, 'Tie::IxHash',
'fieldorder' => [qw(
setup_fee recur_fee
recur_temporality unused_credit recur_method cutoff_day
+ add_full_period
output_format usage_section summarize_usage usage_mandate
)
],
diff --git a/FS/FS/part_pkg/discount_Mixin.pm b/FS/FS/part_pkg/discount_Mixin.pm
new file mode 100644
index 0000000..df65e97
--- /dev/null
+++ b/FS/FS/part_pkg/discount_Mixin.pm
@@ -0,0 +1,128 @@
+package FS::part_pkg::discount_Mixin;
+
+use strict;
+use vars qw(@ISA %info);
+use FS::part_pkg;
+use FS::cust_pkg;
+use FS::cust_bill_pkg_discount;
+use Time::Local qw(timelocal);
+use List::Util 'min';
+
+@ISA = qw(FS::part_pkg);
+%info = ( 'disabled' => 1 );
+
+=head1 NAME
+
+FS::part_pkg::discount_Mixin - Mixin class for part_pkg:: classes that
+can be discounted.
+
+=head1 SYNOPSIS
+
+package FS::part_pkg::...;
+use base qw( FS::part_pkg::discount_Mixin );
+
+sub calc_recur {
+ ...
+ my $discount = $self->calc_discount($cust_pkg, $$sdate, $details, $param);
+ $charge -= $discount;
+ ...
+}
+
+=head METHODS
+
+=item calc_discount
+
+Takes all the arguments of calc_recur. Calculates and returns the amount
+by which to reduce the recurring fee; also increments months used on the
+discount and generates an invoice detail describing it.
+
+=cut
+
+sub calc_discount {
+ my($self, $cust_pkg, $sdate, $details, $param ) = @_;
+
+ my $br = $self->base_recur($cust_pkg);
+
+ my $tot_discount = 0;
+ #UI enforces just 1 for now, will need ordering when they can be stacked
+
+ if ( $param->{freq_override} ) {
+ # When a customer pays for more than one month at a time to receive a
+ # term discount, freq_override is set to the number of months.
+ my $real_part_pkg = new FS::part_pkg { $self->hash };
+ $real_part_pkg->pkgpart($param->{real_pkgpart} || $self->pkgpart);
+ # Find a discount with that duration...
+ my @discount = grep { $_->months == $param->{freq_override} }
+ map { $_->discount } $real_part_pkg->part_pkg_discount;
+ my $discount = shift @discount;
+ # and default to bill that many months at once.
+ $param->{months} = $param->{freq_override} unless $param->{months};
+ my $error;
+ if ($discount) {
+ # Then set the cust_pkg discount.
+ if ($discount->months == $param->{months}) {
+ $cust_pkg->discountnum($discount->discountnum);
+ $error = $cust_pkg->insert_discount;
+ } else {
+ $cust_pkg->discountnum(-1);
+ foreach ( qw( amount percent months ) ) {
+ my $method = "discountnum_$_";
+ $cust_pkg->$method($discount->$_);
+ }
+ $error = $cust_pkg->insert_discount;
+ }
+ die "error discounting using part_pkg_discount: $error" if $error;
+ }
+ }
+
+ my @cust_pkg_discount = $cust_pkg->cust_pkg_discount_active;
+ foreach my $cust_pkg_discount ( @cust_pkg_discount ) {
+ my $discount = $cust_pkg_discount->discount;
+ #UI enforces one or the other (for now? probably for good)
+ my $amount = 0;
+ $amount += $discount->amount
+ if $cust_pkg->pkgpart == $param->{real_pkgpart};
+ $amount += sprintf('%.2f', $discount->percent * $br / 100 );
+ my $chg_months = $param->{'months'} || $cust_pkg->part_pkg->freq;
+
+ my $months = $discount->months
+ ? min( $chg_months,
+ $discount->months - $cust_pkg_discount->months_used )
+ : $chg_months;
+
+ my $error = $cust_pkg_discount->increment_months_used($months)
+ if $cust_pkg->pkgpart == $param->{real_pkgpart};
+ die "error discounting: $error" if $error;
+
+ $amount *= $months;
+ $amount = sprintf('%.2f', $amount);
+
+ next unless $amount > 0;
+
+ #record details in cust_bill_pkg_discount
+ my $cust_bill_pkg_discount = new FS::cust_bill_pkg_discount {
+ 'pkgdiscountnum' => $cust_pkg_discount->pkgdiscountnum,
+ 'amount' => $amount,
+ 'months' => $months,
+ };
+ push @{ $param->{'discounts'} }, $cust_bill_pkg_discount;
+
+ #add details on discount to invoice
+ my $conf = new FS::Conf;
+ my $money_char = $conf->config('money_char') || '$';
+ $months = sprintf('%.2f', $months) if $months =~ /\./;
+
+ my $d = 'Includes ';
+ $d .= $discount->name. ' ' if $discount->name;
+ $d .= 'discount of '. $discount->description_short;
+ $d .= " for $months month". ( $months!=1 ? 's' : '' );
+ $d .= ": $money_char$amount" if $months != 1 || $discount->percent;
+ push @$details, $d;
+
+ $tot_discount += $amount;
+ }
+
+ sprintf('%.2f', $tot_discount);
+}
+
+1;
diff --git a/FS/FS/part_pkg/flat.pm b/FS/FS/part_pkg/flat.pm
index 6db6eee..a17813b 100644
--- a/FS/FS/part_pkg/flat.pm
+++ b/FS/FS/part_pkg/flat.pm
@@ -11,15 +11,21 @@ use List::Util qw(min); # max);
use FS::UI::bytecount;
use FS::Conf;
use FS::part_pkg;
-use FS::cust_bill_pkg_discount;
-@ISA = qw(FS::part_pkg);
+@ISA = qw(FS::part_pkg
+ FS::part_pkg::prorate_Mixin
+ FS::part_pkg::discount_Mixin);
tie my %temporalities, 'Tie::IxHash',
'upcoming' => "Upcoming (future)",
'preceding' => "Preceding (past)",
;
+tie my %contract_years, 'Tie::IxHash', (
+ '' => '(none)',
+ map { $_*12 => $_ } (1..5),
+);
+
%usage_fields = (
'seconds' => { 'name' => 'Time limit for this package',
@@ -115,10 +121,29 @@ tie my %temporalities, 'Tie::IxHash',
#used in cust_pkg.pm so could add to any price plan
'expire_months' => { 'name' => 'Auto-add an expiration date this number of months out',
},
+ 'adjourn_months'=> { 'name' => 'Auto-add a suspension date this number of months out',
+ },
+ 'contract_end_months'=> {
+ 'name' => 'Auto-add a contract end date this number of years out',
+ 'type' => 'select',
+ 'select_options' => \%contract_years,
+ },
#used in cust_pkg.pm so could add to any price plan where it made sense
'start_1st' => { 'name' => 'Auto-add a start date to the 1st, ignoring the current month.',
'type' => 'checkbox',
},
+ 'sync_bill_date' => { 'name' => 'Prorate first month to synchronize '.
+ 'with the customer\'s other packages',
+ 'type' => 'checkbox',
+ },
+ 'suspend_bill' => { 'name' => 'Continue recurring billing while suspended',
+ 'type' => 'checkbox',
+ },
+ 'unsuspend_adjust_bill' =>
+ { 'name' => 'Adjust next bill date forward when '.
+ 'unsuspending',
+ 'type' => 'checkbox',
+ },
%usage_fields,
%usage_recharge_fields,
@@ -129,7 +154,10 @@ tie my %temporalities, 'Tie::IxHash',
},
'fieldorder' => [ qw( setup_fee recur_fee
recur_temporality unused_credit
- expire_months start_1st
+ expire_months adjourn_months
+ contract_end_months
+ start_1st sync_bill_date
+ suspend_bill unsuspend_adjust_bill
),
@usage_fieldorder, @usage_recharge_fieldorder,
qw( externalid ),
@@ -154,11 +182,12 @@ sub calc_setup {
sub unit_setup {
my($self, $cust_pkg, $sdate, $details ) = @_;
- $self->option('setup_fee');
+ $self->option('setup_fee') || 0;
}
sub calc_recur {
- my($self, $cust_pkg, $sdate, $details, $param ) = @_;
+ my $self = shift;
+ my($cust_pkg, $sdate, $details, $param ) = @_;
#my $last_bill = $cust_pkg->last_bill;
my $last_bill = $cust_pkg->get('last_bill'); #->last_bill falls back to setup
@@ -166,67 +195,23 @@ sub calc_recur {
return 0
if $self->option('recur_temporality', 1) eq 'preceding' && $last_bill == 0;
- my $br = $self->base_recur($cust_pkg);
-
- my $discount = $self->calc_discount($cust_pkg, $sdate, $details, $param);
-
- sprintf('%.2f', $br - $discount);
-}
-
-sub calc_discount {
- my($self, $cust_pkg, $sdate, $details, $param ) = @_;
-
- my $br = $self->base_recur($cust_pkg);
-
- my $tot_discount = 0;
- #UI enforces just 1 for now, will need ordering when they can be stacked
- my @cust_pkg_discount = $cust_pkg->cust_pkg_discount_active;
- foreach my $cust_pkg_discount ( @cust_pkg_discount ) {
- my $discount = $cust_pkg_discount->discount;
- #UI enforces one or the other (for now? probably for good)
- my $amount = 0;
- $amount += $discount->amount;
- $amount += sprintf('%.2f', $discount->percent * $br / 100 );
-
- my $chg_months = $param->{'months'} || $cust_pkg->part_pkg->freq;
-
- my $months = $discount->months
- ? min( $chg_months,
- $discount->months - $cust_pkg->months_used )
- : $chg_months;
-
- my $error = $cust_pkg_discount->increment_months_used($months);
- die "error discounting: $error" if $error;
-
- $amount *= $months;
- $amount = sprintf('%.2f', $amount);
-
- next unless $amount > 0;
-
- #record details in cust_bill_pkg_discount
- my $cust_bill_pkg_discount = new FS::cust_bill_pkg_discount {
- 'pkgdiscountnum' => $cust_pkg_discount->pkgdiscountnum,
- 'amount' => $amount,
- 'months' => $months,
- };
- push @{ $param->{'discounts'} }, $cust_bill_pkg_discount;
-
- #add details on discount to invoice
- my $conf = new FS::Conf;
- my $money_char = $conf->config('money_char') || '$';
- $months = sprintf('%.2f', $months) if $months =~ /\./;
-
- my $d = 'Includes ';
- $d .= $discount->name. ' ' if $discount->name;
- $d .= 'discount of '. $discount->description_short;
- $d .= " for $months month". ( $months!=1 ? 's' : '' );
- $d .= ": $money_char$amount" if $months != 1 || $discount->percent;
- push @$details, $d;
-
- $tot_discount += $amount;
+ my $charge = $self->base_recur($cust_pkg);
+ if ( $self->option('sync_bill_date',1) ) {
+ my $next_bill = $cust_pkg->cust_main->next_bill_date;
+ if ( defined($next_bill) and $next_bill != $$sdate ) {
+ my $cutoff_day = (localtime($next_bill))[3];
+ $charge = $self->calc_prorate(@_, $cutoff_day);
+ }
+ }
+ elsif ( $param->{freq_override} ) {
+ # XXX not sure if this should be mutually exclusive with sync_bill_date.
+ # Given the very specific problem that freq_override is meant to 'solve',
+ # it probably should.
+ $charge *= $param->{freq_override} if $param->{freq_override};
}
- sprintf('%.2f', $tot_discount);
+ my $discount = $self->calc_discount($cust_pkg, $sdate, $details, $param);
+ return sprintf('%.2f', $charge - $discount);
}
sub base_recur {
diff --git a/FS/FS/part_pkg/prorate.pm b/FS/FS/part_pkg/prorate.pm
index 09561cf..4abdb8d 100644
--- a/FS/FS/part_pkg/prorate.pm
+++ b/FS/FS/part_pkg/prorate.pm
@@ -79,6 +79,15 @@ use FS::part_pkg::flat;
'package recharge',
'type' => 'checkbox',
},
+ 'add_full_period'=> { 'name' => 'When prorating first month, also bill '.
+ 'for one full period after that',
+ 'type' => 'checkbox',
+ },
+ 'prorate_round_day'=> {
+ 'name' => 'When prorating first month, round to '.
+ 'the nearest full day',
+ 'type' => 'checkbox',
+ },
#it would be better if this had to be turned on, its confusing
'externalid' => { 'name' => 'Optional External ID',
@@ -89,40 +98,16 @@ use FS::part_pkg::flat;
'seconds', 'upbytes', 'downbytes', 'totalbytes',
'recharge_amount', 'recharge_seconds', 'recharge_upbytes',
'recharge_downbytes', 'recharge_totalbytes',
- 'usage_rollover', 'recharge_reset', 'externalid', ],
+ 'usage_rollover', 'recharge_reset', 'add_full_period',
+ 'prorate_round_day', 'externalid', ],
'freq' => 'm',
'weight' => 20,
);
sub calc_recur {
- my($self, $cust_pkg, $sdate, $details, $param ) = @_;
- my $cutoff_day = $self->option('cutoff_day', 1) || 1;
- my $mnow = $$sdate;
- my ($sec,$min,$hour,$mday,$mon,$year) = (localtime($mnow) )[0,1,2,3,4,5];
- my $mend;
- my $mstart;
-
- if ( $mday >= $cutoff_day ) {
- $mend =
- timelocal(0,0,0,$cutoff_day, $mon == 11 ? 0 : $mon+1, $year+($mon==11));
- $mstart =
- timelocal(0,0,0,$cutoff_day,$mon,$year);
-
- } else {
- $mend = timelocal(0,0,0,$cutoff_day, $mon, $year);
- if ($mon==0) {$mon=11;$year--;} else {$mon--;}
- $mstart= timelocal(0,0,0,$cutoff_day,$mon,$year);
- }
-
- $$sdate = $mstart;
- my $permonth = $self->option('recur_fee') / $self->freq;
-
- my $months = ( ( $self->freq - 1 ) + ($mend-$mnow) / ($mend-$mstart) );
-
- $param->{'months'} = $months;
- my $discount = $self->calc_discount( $cust_pkg, $sdate, $details, $param);
-
- sprintf('%.2f', $permonth * $months - $discount);
+ my $self = shift;
+ my $cutoff_day = $self->option('cutoff_day') || 1;
+ return $self->calc_prorate(@_, $cutoff_day) - $self->calc_discount(@_);
}
1;
diff --git a/FS/FS/part_pkg/prorate_Mixin.pm b/FS/FS/part_pkg/prorate_Mixin.pm
new file mode 100644
index 0000000..9c0c266
--- /dev/null
+++ b/FS/FS/part_pkg/prorate_Mixin.pm
@@ -0,0 +1,94 @@
+package FS::part_pkg::prorate_Mixin;
+
+use strict;
+use vars qw(@ISA %info);
+use Time::Local qw(timelocal);
+
+@ISA = qw(FS::part_pkg);
+%info = (
+ 'disabled' => 1,
+);
+
+=head1 NAME
+
+FS::part_pkg::prorate_Mixin - Mixin class for part_pkg:: classes that
+need to prorate partial months
+
+=head1 SYNOPSIS
+
+package FS::part_pkg::...;
+use base qw( FS::part_pkg::prorate_Mixin );
+
+sub calc_recur {
+ ...
+ if( conditions that trigger prorate ) {
+ # sets $$sdate and $param->{'months'}, returns the prorated charge
+ $charges = $self->calc_prorate($cust_pkg, $sdate, $param, $cutoff_day);
+ }
+ ...
+}
+
+=head METHODS
+
+=item calc_prorate CUST_PKG
+
+Takes all the arguments of calc_recur, followed by a day of the month
+to prorate to. Calculates a prorated charge from the $sdate to that day,
+and sets the $sdate and $param->{months} accordingly.
+
+Options:
+- recur_fee: The charge to use for a complete billing period.
+- add_full_period: Bill for the time up to the prorate day plus one full
+billing period after that.
+- prorate_round_day: Round the current time to the nearest full day,
+instead of using the exact time.
+
+=cut
+
+sub calc_prorate {
+ my $self = shift;
+ my ($cust_pkg, $sdate, $details, $param, $cutoff_day) = @_;
+
+ my $charge = $self->option('recur_fee',1) || 0;
+ if($cutoff_day) {
+ # only works for freq >= 1 month; probably can't be fixed
+ my $mnow = $$sdate;
+ my ($sec, $min, $hour, $mday, $mon, $year) = (localtime($mnow))[0..5];
+ if( $self->option('prorate_round_day',1) ) {
+ $mday++ if $hour >= 12;
+ $mnow = timelocal(0,0,0,$mday,$mon,$year);
+ }
+ my $mend;
+ my $mstart;
+ if ( $mday >= $cutoff_day ) {
+ $mend =
+ timelocal(0,0,0,$cutoff_day,$mon == 11 ? 0 : $mon + 1,$year+($mon==11));
+ $mstart =
+ timelocal(0,0,0,$cutoff_day,$mon,$year);
+ }
+ else {
+ $mend =
+ timelocal(0,0,0,$cutoff_day,$mon,$year);
+ $mstart =
+ timelocal(0,0,0,$cutoff_day,$mon == 0 ? 11 : $mon - 1,$year-($mon==11));
+ }
+
+ # next bill date will be figured as $$sdate + one period
+ $$sdate = $mstart;
+
+ my $permonth = $charge / $self->freq;
+ my $months = ( ( $self->freq - 1 ) + ($mend-$mnow) / ($mend-$mstart) );
+
+ if ( $self->option('add_full_period',1) ) {
+ # charge a full period in addition to the partial month
+ $months += $self->freq;
+ $$sdate = $self->add_freq($mstart);
+ }
+
+ $param->{'months'} = $months;
+ $charge = sprintf('%.2f', $permonth * $months);
+ }
+ return $charge;
+}
+
+1;
diff --git a/FS/FS/part_pkg/recur_Common.pm b/FS/FS/part_pkg/recur_Common.pm
index 8ed9eb6..7614d7a 100644
--- a/FS/FS/part_pkg/recur_Common.pm
+++ b/FS/FS/part_pkg/recur_Common.pm
@@ -4,9 +4,9 @@ use strict;
use vars qw( @ISA %info %recur_method );
use Tie::IxHash;
use Time::Local;
-use FS::part_pkg::prorate;
+use FS::part_pkg::flat;
-@ISA = qw(FS::part_pkg::prorate);
+@ISA = qw(FS::part_pkg::flat);
%info = ( 'disabled' => 1 ); #recur_Common not a usable price plan directly
@@ -16,6 +16,11 @@ tie %recur_method, 'Tie::IxHash',
'subscription' => 'Charge the full fee for the first partial period (selectable billing date)',
;
+sub base_recur {
+ my $self = shift;
+ $self->option('recur_fee', 1) || 0;
+}
+
sub calc_recur_Common {
my $self = shift;
my($cust_pkg, $sdate, $details, $param ) = @_; #only need $sdate & $param
@@ -25,36 +30,40 @@ sub calc_recur_Common {
if ( $param->{'increment_next_bill'} ) {
my $recur_method = $self->option('recur_method', 1) || 'anniversary';
-
- if ( $recur_method eq 'prorate' ) {
-
- $charges = $self->SUPER::calc_recur(@_);
+
+ $charges = $self->base_recur;
- } else {
-
- $charges = $self->option('recur_fee');
-
- if ( $recur_method eq 'subscription' ) {
-
- my $cutoff_day = $self->option('cutoff_day', 1) || 1;
- my ($day, $mon, $year) = ( localtime($$sdate) )[ 3..5 ];
+ if ( $recur_method eq 'prorate' ) {
+ my $cutoff_day = $self->option('cutoff_day') || 1;
+ $charges = $self->calc_prorate(@_, $cutoff_day);
+ }
+ elsif ( $recur_method eq 'anniversary' and
+ $self->option('sync_bill_date',1) ) {
+ my $next_bill = $cust_pkg->cust_main->next_bill_date;
+ if ( defined($next_bill) ) {
+ my $cutoff_day = (localtime($next_bill))[3];
+ $charges = $self->calc_prorate(@_, $cutoff_day);
+ }
+ }
+ elsif ( $recur_method eq 'subscription' ) {
- if ( $day < $cutoff_day ) {
- if ( $mon == 0 ) { $mon=11; $year--; }
- else { $mon--; }
- }
+ my $cutoff_day = $self->option('cutoff_day', 1) || 1;
+ my ($day, $mon, $year) = ( localtime($$sdate) )[ 3..5 ];
- $$sdate = timelocal(0, 0, 0, $cutoff_day, $mon, $year);
+ if ( $day < $cutoff_day ) {
+ if ( $mon == 0 ) { $mon=11; $year--; }
+ else { $mon--; }
+ }
- }#$recur_method eq 'subscription'
+ $$sdate = timelocal(0, 0, 0, $cutoff_day, $mon, $year);
- $charges -= $self->calc_discount( $cust_pkg, $sdate, $details, $param );
+ }#$recur_method eq 'subscription'
- }#$recur_method eq 'prorate'
+ $charges -= $self->calc_discount( $cust_pkg, $sdate, $details, $param );
}#increment_next_bill
- $charges;
+ return $charges;
}
diff --git a/FS/FS/part_pkg/sql_external.pm b/FS/FS/part_pkg/sql_external.pm
index 3cf2064..8e803f9 100644
--- a/FS/FS/part_pkg/sql_external.pm
+++ b/FS/FS/part_pkg/sql_external.pm
@@ -24,6 +24,10 @@ use DBI;
'subscription',
'default' => '1',
},
+ 'add_full_period'=> { 'name' => 'When prorating first month, also bill '.
+ 'for one full period after that',
+ 'type' => 'checkbox',
+ },
'recur_method' => { 'name' => 'Recurring fee method',
#'type' => 'radio',
@@ -45,7 +49,7 @@ use DBI;
},
},
'fieldorder' => [qw( setup_fee recur_fee unused_credit recur_method cutoff_day
- datasrc db_username db_password query
+ add_full_period datasrc db_username db_password query
)],
'weight' => '58',
);
diff --git a/FS/FS/part_pkg/subscription.pm b/FS/FS/part_pkg/subscription.pm
index a5e0262..5495e3a 100644
--- a/FS/FS/part_pkg/subscription.pm
+++ b/FS/FS/part_pkg/subscription.pm
@@ -18,7 +18,7 @@ use FS::part_pkg::flat;
'recur_fee' => { 'name' => 'Recurring fee for this package',
'default' => 0,
},
- 'cutoff_day' => { 'name' => 'billing day',
+ 'cutoff_day' => { 'name' => 'Billing day',
'default' => 1,
},
'seconds' => { 'name' => 'Time limit for this package',
diff --git a/FS/FS/part_pkg/voip_cdr.pm b/FS/FS/part_pkg/voip_cdr.pm
index 1d2f673..768f894 100644
--- a/FS/FS/part_pkg/voip_cdr.pm
+++ b/FS/FS/part_pkg/voip_cdr.pm
@@ -17,11 +17,12 @@ use List::Util qw(first min);
@ISA = qw(FS::part_pkg::recur_Common);
-$DEBUG = 1;
+$DEBUG = 0;
tie my %cdr_svc_method, 'Tie::IxHash',
'svc_phone.phonenum' => 'Phone numbers (svc_phone.phonenum)',
'svc_pbx.title' => 'PBX name (svc_pbx.title)',
+ 'svc_pbx.svcnum' => 'Freeside service # (svc_pbx.svcnum)',
;
tie my %rating_method, 'Tie::IxHash',
@@ -70,7 +71,10 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'subscription',
'default' => '1',
},
-
+ 'add_full_period'=> { 'name' => 'When prorating first month, also bill '.
+ 'for one full period after that',
+ 'type' => 'checkbox',
+ },
'recur_method' => { 'name' => 'Recurring fee method',
#'type' => 'radio',
#'options' => \%recur_method,
@@ -95,6 +99,10 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'select_label' => 'ratename',
},
+ 'min_included' => { 'name' => 'Minutes included when using "single price per minute" rating method',
+ },
+
+
'min_charge' => { 'name' => 'Charge per minute when using "single price per minute" rating method',
},
@@ -149,10 +157,10 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'use_cdrtypenum' => { 'name' => 'Do not charge for CDRs where the CDR Type is not set to: ',
},
- 'skip_dst_prefix' => { 'name' => 'Do not charge for CDRs where the destination number starts with any of these values:',
+ 'skip_dst_prefix' => { 'name' => 'Do not charge for CDRs where the destination number starts with any of these values: ',
},
- 'skip_dcontext' => { 'name' => 'Do not charge for CDRs where the dcontext is set to any of these (comma-separated) values:',
+ 'skip_dcontext' => { 'name' => 'Do not charge for CDRs where the dcontext is set to any of these (comma-separated) values: ',
},
'skip_dstchannel_prefix' => { 'name' => 'Do not charge for CDRs where the dstchannel starts with:',
@@ -161,12 +169,12 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'skip_src_length_more' => { 'name' => 'Do not charge for CDRs where the source is more than this many digits:',
},
- 'noskip_src_length_accountcode_tollfree' => { 'name' => 'Do charge for CDRs where source is equal or greater than the specified digits and accountcode is toll free',
+ 'noskip_src_length_accountcode_tollfree' => { 'name' => 'Do charge for CDRs where source is equal or greater than the specified digits, when accountcode is toll free',
'type' => 'checkbox',
},
'accountcode_tollfree_ratenum' => {
- 'name' => 'Optional alternate rate plan when accountcode is toll free',
+ 'name' => 'Optional alternate rate plan when accountcode is toll free: ',
'type' => 'select',
'select_table' => 'rate',
'select_key' => 'ratenum',
@@ -178,9 +186,16 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'skip_dst_length_less' => { 'name' => 'Do not charge for CDRs where the destination is less than this many digits:',
},
- 'skip_lastapp' => { 'name' => 'Do not charge for CDRs where the lastapp matches this value',
+ 'noskip_dst_length_accountcode_tollfree' => { 'name' => 'Do charge for CDRs where dst is less than the specified digits, when accountcode is toll free',
+ 'type' => 'checkbox',
+ },
+
+ 'skip_lastapp' => { 'name' => 'Do not charge for CDRs where the lastapp matches this value: ',
},
+ 'skip_max_callers' => { 'name' => 'Do not charge for CDRs where max_callers is less than or equal to this value: ',
+ },
+
'use_duration' => { 'name' => 'Calculate usage based on the duration field instead of the billsec field',
'type' => 'checkbox',
},
@@ -195,7 +210,7 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'default' => 'default', #XXX test
},
- 'usage_section' => { 'name' => 'Section in which to place usage charges (whether separated or not)',
+ 'usage_section' => { 'name' => 'Section in which to place usage charges (whether separated or not): ',
},
'summarize_usage' => { 'name' => 'Include usage summary with recurring charges when usage is in separate section',
@@ -207,10 +222,14 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
},
#eofalse
- 'bill_every_call' => { 'name' => 'Generate an invoice immediately for every call. Useful for prepaid.',
+ 'bill_every_call' => { 'name' => 'Generate an invoice immediately for every call (as well any setup fee, upon first payment). Useful for prepaid.',
'type' => 'checkbox',
},
+ 'bill_inactive_svcs' => { 'name' => 'Bill for all phone numbers that were active during the billing period',
+ 'type' => 'checkbox',
+ },
+
'count_available_phones' => { 'name' => 'Consider for tax purposes the number of lines to be svc_phones that may be provisioned rather than those that actually are.',
'type' => 'checkbox',
},
@@ -242,6 +261,7 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'fieldorder' => [qw(
setup_fee recur_fee recur_temporality unused_credit
recur_method cutoff_day
+ add_full_period
cdr_svc_method
rating_method ratenum min_charge sec_granularity
ignore_unrateable
@@ -255,11 +275,14 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
skip_dstchannel_prefix skip_src_length_more
noskip_src_length_accountcode_tollfree
accountcode_tollfree_ratenum
- skip_dst_length_less skip_lastapp
+ skip_dst_length_less
+ noskip_dst_length_accountcode_tollfree
+ skip_lastapp
+ skip_max_callers
use_duration
411_rewrite
output_format usage_mandate summarize_usage usage_section
- bill_every_call
+ bill_every_call bill_inactive_svcs
count_available_phones
)
],
@@ -314,7 +337,7 @@ sub calc_usage {
# my $downstream_cdr = '';
- my $cdr_svc_method = $self->option('cdr_svc_method')||'svc_phone.phonenum';
+ my $cdr_svc_method = $self->option('cdr_svc_method',1)||'svc_phone.phonenum';
my $rating_method = $self->option('rating_method') || 'prefix';
my $intl = $self->option('international_prefix') || '011';
my $domestic_prefix = $self->option('domestic_prefix');
@@ -346,18 +369,35 @@ sub calc_usage {
my($svc_table, $svc_field) = split('\.', $cdr_svc_method);
- foreach my $cust_svc (
- grep { $_->part_svc->svcdb eq $svc_table } $cust_pkg->cust_svc
- ) {
+ my @cust_svc;
+ if( $self->option('bill_inactive_svcs',1) ) {
+ #XXX in this mode do we need to restrict the set of CDRs by date also?
+ @cust_svc = $cust_pkg->h_cust_svc($$sdate, $last_bill);
+ }
+ else {
+ @cust_svc = $cust_pkg->cust_svc;
+ }
+ @cust_svc = grep { $_->part_svc->svcdb eq $svc_table } @cust_svc;
- my $svc_x = $cust_svc->svc_x;
- foreach my $cdr (
- $svc_x->get_cdrs(
+ foreach my $cust_svc (@cust_svc) {
+
+ my $svc_x;
+ if( $self->option('bill_inactive_svcs',1) ) {
+ $svc_x = $cust_svc->h_svc_x($$sdate, $last_bill);
+ }
+ else {
+ $svc_x = $cust_svc->svc_x;
+ }
+ my %options = (
'disable_src' => $self->option('disable_src'),
'default_prefix' => $self->option('default_prefix'),
'status' => '',
'for_update' => 1,
- ) # $last_bill, $$sdate )
+ ); # $last_bill, $$sdate )
+ $options{'by_svcnum'} = 1 if $svc_field eq 'svcnum';
+
+ foreach my $cdr (
+ $svc_x->get_cdrs( %options )
) {
if ( $DEBUG > 1 ) {
warn "rating CDR $cdr\n".
@@ -507,38 +547,6 @@ sub calc_usage {
}
-# } elsif ( $rating_method eq 'upstream' ) { #XXX this was convergent, not currently used. very much becoming the odd one out. remove?
-#
-# if ( $cdr->cdrtypenum == 1 ) { #rate based on upstream rateid
-#
-# $rate_detail = $cdr->cdr_upstream_rate->rate_detail;
-#
-# $regionnum = $rate_detail->dest_regionnum;
-# $rate_region = $rate_detail->dest_region;
-#
-# $pretty_destnum = $cdr->dst;
-#
-# warn " found rate for regionnum $regionnum and ".
-# "rate detail $rate_detail\n"
-# if $DEBUG;
-#
-# } else { #pass upstream price through
-#
-# $charge = sprintf('%.2f', $cdr->upstream_price);
-# warn "Incrementing \$charges by $charge. Now $charges\n" if $DEBUG;
-# $charges += $charge;
-#
-# @call_details = (
-# #time2str("%Y %b %d - %r", $cdr->calldate_unix ),
-# time2str("%c", $cdr->calldate_unix), #XXX this should probably be a config option dropdown so they can select US vs- rest of world dates or whatnot
-# 'N/A', #minutes...
-# '$'.$charge,
-# #$pretty_destnum,
-# $cdr->description, #$rate_region->regionname,
-# );
-#
-# }
-
} elsif ( $rating_method eq 'upstream_simple' ) {
#XXX $charge = sprintf('%.2f', $cdr->upstream_price);
@@ -555,20 +563,19 @@ sub calc_usage {
} elsif ( $rating_method eq 'single_price' ) {
# a little false laziness w/below
+ # $rate_detail = new FS::rate_detail({sec_granularity => ... }) ?
my $granularity = length($self->option('sec_granularity'))
? $self->option('sec_granularity')
: 60;
- # length($cdr->billsec) ? $cdr->billsec : $cdr->duration;
$seconds = $use_duration ? $cdr->duration : $cdr->billsec;
$seconds += $granularity - ( $seconds % $granularity )
if $seconds # don't granular-ize 0 billsec calls (bills them)
- && $granularity; # 0 is per call
- my $minutes = $seconds / 60; # sprintf("%.1f",
- #$minutes =~ s/\.0$// if $granularity == 60;
-
+ && $granularity # 0 is per call
+ && $seconds % $granularity;
+ my $minutes = $seconds / 60;
# XXX config?
#$charge = sprintf('%.2f', ( $self->option('min_charge') * $minutes )
#+ 0.00000001 ); #so 1.005 rounds to 1.01
@@ -578,8 +585,12 @@ sub calc_usage {
warn "Incrementing \$charges by $charge. Now $charges\n" if $DEBUG;
$charges += $charge;
- @call_details = ($cdr->downstream_csv( 'format' => $output_format,
- 'charge' => $charge,
+ @call_details = ($cdr->downstream_csv( 'format' => $output_format,
+ 'charge' => $charge,
+ 'seconds' => ($use_duration ?
+ $cdr->duration :
+ $cdr->billsec),
+ 'granularity' => $granularity,
)
);
@@ -602,6 +613,9 @@ sub calc_usage {
"; skipping\n"
} else { # there *is* a rate_detail (or call_details), proceed...
+ # About this section:
+ # We don't round _anything_ (except granularizing)
+ # until the final $charge = sprintf("%.2f"...).
unless ( @call_details || ( $charge ne '' && $charge == 0 ) ) {
@@ -610,10 +624,8 @@ sub calc_usage {
$seconds = min($seconds_left, $rate_detail->conn_sec);
$seconds_left -= $seconds;
$weektime += $seconds;
- $charge = sprintf("%.02f", $rate_detail->conn_charge);
+ $charge = $rate_detail->conn_charge;
- my $total_minutes = 0;
- my $whole_minutes = 1;
my $etime;
while($seconds_left) {
my $ratetimenum = $rate_detail->ratetimenum; # may be empty
@@ -657,29 +669,28 @@ sub calc_usage {
unless exists $included_min{$regionnum}{$ratetimenum};
my $granularity = $rate_detail->sec_granularity;
- $whole_minutes = 0 if $granularity;
- # should this be done in every rate interval?
- $charge_sec += $granularity - ( $charge_sec % $granularity )
- if $charge_sec # don't granular-ize 0 billsec calls (bills them)
- && $granularity; # 0 is per call
- my $minutes = sprintf("%.1f", $charge_sec / 60);
- $minutes =~ s/\.0$// if $granularity == 60;
+ my $minutes;
+ if ( $granularity ) { # charge per minute
+ # Round up to the nearest $granularity
+ if ( $charge_sec and $charge_sec % $granularity ) {
+ $charge_sec += $granularity - ($charge_sec % $granularity);
+ }
+ $minutes = $charge_sec / 60; #don't round this
+ }
+ else { # per call
+ $minutes = 1;
+ $seconds_left = 0;
+ }
$seconds += $charge_sec;
- # per call rather than per minute
- $minutes = 1 unless $granularity;
- $seconds_left = 0 unless $granularity;
-
$included_min{$regionnum}{$ratetimenum} -= $minutes;
-
if ( $included_min{$regionnum}{$ratetimenum} <= 0 ) {
my $charge_min = 0 - $included_min{$regionnum}{$ratetimenum}; #XXX should preserve
#(display?) this
$included_min{$regionnum}{$ratetimenum} = 0;
- $charge += sprintf('%.2f', ($rate_detail->min_charge * $charge_min)
- + 0.00000001 ); #so 1.005 rounds to 1.01
+ $charge += ($rate_detail->min_charge * $charge_min); #still not rounded
}
# choose next rate_detail
@@ -694,20 +705,17 @@ sub calc_usage {
# this is why we need regionnum/rate_region....
warn " (rate region $rate_region)\n" if $DEBUG;
- $total_minutes = sprintf("%.1f", $seconds / 60);
- $total_minutes =~ s/\.0$// if $whole_minutes;
-
$classnum = $rate_detail->classnum;
- $charge = sprintf('%.2f', $charge);
+ $charge = sprintf('%.2f', $charge + 0.000001); # NOW round it.
+ warn "Incrementing \$charges by $charge. Now $charges\n" if $DEBUG;
+ $charges += $charge;
@call_details = (
$cdr->downstream_csv( 'format' => $output_format,
'granularity' => $rate_detail->sec_granularity,
- 'minutes' => $total_minutes,
- # why do we go through this hocus-pocus?
- # the cdr *will* show duration here
- # if we forego the 'minutes' key
- # duration vs billsec?
+ 'seconds' => ($use_duration ?
+ $cdr->duration :
+ $cdr->billsec),
'charge' => $charge,
'pretty_dst' => $pretty_destnum,
'dst_regionname' => $regionname,
@@ -718,12 +726,9 @@ sub calc_usage {
if ( $charge > 0 ) {
#just use FS::cust_bill_pkg_detail objects?
- warn "Incrementing \$charges by $charge. Now $charges\n" if $DEBUG;
- $charges += $charge;
my $call_details;
- my $phonenum = $cust_svc->svc_x->phonenum;
+ my $phonenum = $svc_x->phonenum;
- #if ( $self->option('rating_method') eq 'upstream_simple' ) {
if ( scalar(@call_details) == 1 ) {
$call_details =
[ 'C',
@@ -827,8 +832,9 @@ sub check_chargable {
skip_dcontext
skip_dstchannel_prefix
skip_src_length_more noskip_src_length_accountcode_tollfree
- skip_dst_length_less
+ skip_dst_length_less noskip_dst_length_accountcode_tollfree
skip_lastapp
+ skip_max_callers
);
foreach my $opt (grep !exists($flags{option_cache}->{$_}), @opt ) {
$flags{option_cache}->{$opt} = $self->option($opt, 1);
@@ -869,7 +875,10 @@ sub check_chargable {
my $dst_length = $opt{'skip_dst_length_less'};
return "destination less than $dst_length digits"
- if $dst_length && length($cdr->dst) < $dst_length;
+ if $dst_length && length($cdr->dst) < $dst_length
+ && ! ( $opt{'noskip_dst_length_accountcode_tollfree'}
+ && $cdr->is_tollfree('accountcode')
+ );
return "lastapp is $opt{'skip_lastapp'}"
if length($opt{'skip_lastapp'}) && $cdr->lastapp eq $opt{'skip_lastapp'};
@@ -894,6 +903,11 @@ sub check_chargable {
}
+ return "max_callers <= $opt{skip_max_callers}"
+ if length($opt{'skip_max_callers'})
+ and length($cdr->max_callers)
+ and $cdr->max_callers <= $opt{'skip_max_callers'};
+
#all right then, rate it
'';
}
diff --git a/FS/FS/part_pkg/voip_inbound.pm b/FS/FS/part_pkg/voip_inbound.pm
index 4937bb8..8b1844a 100644
--- a/FS/FS/part_pkg/voip_inbound.pm
+++ b/FS/FS/part_pkg/voip_inbound.pm
@@ -47,6 +47,10 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
'subscription',
'default' => '1',
},
+ 'add_full_period'=> { 'name' => 'When prorating first month, also bill '.
+ 'for one full period after that',
+ 'type' => 'checkbox',
+ },
'recur_method' => { 'name' => 'Recurring fee method',
'type' => 'select',
@@ -152,7 +156,7 @@ tie my %granularity, 'Tie::IxHash', FS::rate_detail::granularities();
},
'fieldorder' => [qw(
setup_fee recur_fee recur_temporality unused_credit
- recur_method cutoff_day
+ recur_method cutoff_day add_full_period
min_charge sec_granularity
default_prefix
disable_tollfree
diff --git a/FS/FS/part_pkg_discount.pm b/FS/FS/part_pkg_discount.pm
new file mode 100644
index 0000000..2187e10
--- /dev/null
+++ b/FS/FS/part_pkg_discount.pm
@@ -0,0 +1,129 @@
+package FS::part_pkg_discount;
+
+use strict;
+use base qw( FS::Record );
+use FS::Record qw( qsearch qsearchs );
+use FS::discount;
+use FS::part_pkg;
+
+=head1 NAME
+
+FS::part_pkg_discount - Object methods for part_pkg_discount records
+
+=head1 SYNOPSIS
+
+ use FS::part_pkg_discount;
+
+ $record = new FS::part_pkg_discount \%hash;
+ $record = new FS::part_pkg_discount { 'column' => 'value' };
+
+ $error = $record->insert;
+
+ $error = $new_record->replace($old_record);
+
+ $error = $record->delete;
+
+ $error = $record->check;
+
+=head1 DESCRIPTION
+
+An FS::part_pkg_discount object represents a link from a package definition
+to a discount. This permits discounts for lengthened terms. FS::part_pkg_discount inherits from
+FS::Record. The following fields are currently supported:
+
+=over 4
+
+=item pkgdiscountnum
+
+primary key
+
+=item pkgpart
+
+pkgpart
+
+=item discountnum
+
+discountnum
+
+
+=back
+
+=head1 METHODS
+
+=over 4
+
+=item new HASHREF
+
+Creates a new part_pkg_discount. 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 { 'part_pkg_discount'; }
+
+=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_numbern('pkgdiscountnum')
+ || $self->ut_number('pkgpart')
+ || $self->ut_number('discountnum')
+ ;
+ return $error if $error;
+
+ $self->SUPER::check;
+}
+
+=item discount
+
+Returns the discount associated with this part_pkg_discount.
+
+=cut
+
+sub discount {
+ my $self = shift;
+ qsearch('discount', { 'discountnum' => $self->discountnum });
+}
+
+=back
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+L<FS::Record>, schema.html from the base documentation.
+
+=cut
+
+1;
+
diff --git a/FS/FS/part_pkg_option.pm b/FS/FS/part_pkg_option.pm
index 3cb330b..142622b 100644
--- a/FS/FS/part_pkg_option.pm
+++ b/FS/FS/part_pkg_option.pm
@@ -137,6 +137,10 @@ sub _upgrade_data { # class method
$sth = dbh->prepare($sql) or die dbh->errstr;
$sth->execute or die $sth->errstr;
+ $sql = "UPDATE part_pkg_option SET optionvalue = NULL WHERE ".
+ "optionname = 'contract_end_months' AND optionvalue = '(none)'";
+ $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute or die $sth->errstr;
'';
}
diff --git a/FS/FS/pay_batch.pm b/FS/FS/pay_batch.pm
index 3abb06d..afff261 100644
--- a/FS/FS/pay_batch.pm
+++ b/FS/FS/pay_batch.pm
@@ -7,6 +7,7 @@ use Text::CSV_XS;
use FS::Record qw( dbh qsearch qsearchs );
use FS::cust_pay;
use FS::Conf;
+use Business::CreditCard qw(cardtype);
@ISA = qw(FS::Record);
@@ -198,6 +199,8 @@ sub import_results {
my $job = $param->{'job'};
$job->update_statustext(0) if $job;
+ my $conf = new FS::Conf;
+
my $filetype = $info->{'filetype'}; # CSV or fixed
my @fields = @{ $info->{'fields'}};
my $formatre = $info->{'formatre'}; # for fixed
@@ -356,12 +359,21 @@ sub import_results {
return "error updating status of paybatchnum $hash{'paybatchnum'}: $error\n";
}
+ # purge CVV when the batch is processed
+ if ( $payby =~ /^(CARD|DCRD)$/ ) {
+ my $payinfo = $hash{'payinfo'} || $cust_pay_batch->payinfo;
+ if ( ! grep { $_ eq cardtype($payinfo) }
+ $conf->config('cvv-save') ) {
+ $new_cust_pay_batch->cust_main->remove_cvv;
+ }
+ }
+
if ( $new_cust_pay_batch->status =~ /Approved/i ) {
my $cust_pay = new FS::cust_pay ( {
'custnum' => $custnum,
'payby' => $payby,
- 'paybatch' => $self->batchnum,
+ 'paybatch' => $hash{'paybatch'} || $self->batchnum,
'payinfo' => ( $hash{'payinfo'} || $cust_pay_batch->payinfo ),
map { $_ => $hash{$_} } (qw( paid _date )),
} );
@@ -478,29 +490,34 @@ sub export_batch {
$batch .= $h . "\n";
}
foreach my $cust_pay_batch (@cust_pay_batch) {
- if($first_download) {
+
+ if ($first_download) {
my $balance = $cust_pay_batch->cust_main->balance;
- my $error = '';
- if($balance <= 0) { # then don't charge this customer
- $error = $cust_pay_batch->delete;
- undef $cust_pay_batch;
- }
- elsif($balance < $cust_pay_batch->amount) { # then reduce the charge to the remaining balance
+ if ($balance <= 0) { # then don't charge this customer
+ my $error = $cust_pay_batch->delete;
+ if ( $error ) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }
+ next;
+ } elsif ($balance < $cust_pay_batch->amount) {
+ # reduce the charge to the remaining balance
$cust_pay_batch->amount($balance);
- $error = $cust_pay_batch->replace;
+ my $error = $cust_pay_batch->replace;
+ if ( $error ) {
+ $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
+ die $error;
+ }
}
# else $balance >= $cust_pay_batch->amount
- if($error) {
- $dbh->rollback or die $dbh->errstr if $oldAutoCommit;
- die $error;
- }
- }
- if($cust_pay_batch) { # that is, it wasn't deleted
- $batchcount++;
- $batchtotal += $cust_pay_batch->amount;
- $batch .= &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal) . "\n";
}
+
+ $batchcount++;
+ $batchtotal += $cust_pay_batch->amount;
+ $batch .= &{$info->{'row'}}($cust_pay_batch, $self, $batchcount, $batchtotal) . "\n";
+
}
+
my $f = $info->{'footer'};
if(ref($f) eq 'CODE') {
$batch .= &$f($self, $batchcount, $batchtotal) . "\n";
diff --git a/FS/FS/pay_batch/paymentech.pm b/FS/FS/pay_batch/paymentech.pm
index 89f0c77..a5b6f27 100644
--- a/FS/FS/pay_batch/paymentech.pm
+++ b/FS/FS/pay_batch/paymentech.pm
@@ -2,6 +2,7 @@ package FS::pay_batch::paymentech;
use strict;
use vars qw(@ISA %import_info %export_info $name);
+use FS::Record 'qsearchs';
use Time::Local;
use Date::Format 'time2str';
use Date::Parse 'str2time';
@@ -12,6 +13,8 @@ my $conf;
my ($bin, $merchantID, $terminalID, $username);
$name = 'paymentech';
+my $gateway;
+
%import_info = (
filetype => 'XML',
xmlrow => [ qw(transResponse newOrderResp) ],
@@ -19,18 +22,42 @@ $name = 'paymentech';
'paybatchnum',
'_date',
'approvalStatus',
+ 'order_number',
+ 'authorization',
],
xmlkeys => [
'orderID',
'respDateTime',
'approvalStatus',
+ 'txRefNum',
+ 'authorizationCode',
],
'hook' => sub {
+ if ( !$gateway ) {
+ # find a gateway configuration that has the same merchantID
+ # as the batch config, if there is one. If not, leave
+ # gateway out entirely.
+ my $merchant = (FS::Conf->new->config('batchconfig-paymentech'))[2];
+ my $g = qsearchs({
+ 'table' => 'payment_gateway',
+ 'addl_from' => ' JOIN payment_gateway_option USING (gatewaynum) ',
+ 'hashref' => { disabled => '',
+ optionname => 'merchant_id',
+ optionvalue => $merchant,
+ },
+ });
+ $gateway = ($g ? $g->gatewaynum . '-' : '') . 'PaymenTech';
+ }
my ($hash, $oldhash) = @_;
my ($mon, $day, $year, $hour, $min, $sec) =
$hash->{'_date'} =~ /^(..)(..)(....)(..)(..)(..)$/;
$hash->{'_date'} = timelocal($sec, $min, $hour, $day, $mon-1, $year);
$hash->{'paid'} = $oldhash->{'amount'};
+ $hash->{'paybatch'} = join(':',
+ $gateway,
+ $hash->{'authorization'},
+ $hash->{'order_number'},
+ );
},
'approved' => sub { my $hash = shift;
$hash->{'approvalStatus'}
diff --git a/FS/FS/rate_time_interval.pm b/FS/FS/rate_time_interval.pm
index 1a82edb..6a9986b 100644
--- a/FS/FS/rate_time_interval.pm
+++ b/FS/FS/rate_time_interval.pm
@@ -158,7 +158,7 @@ sub description {
return map {
sprintf('%s %02d:%02d %s',
$days[int($_/86400) % 7],
- int($_/3600) % 12,
+ (int($_/3600) % 12 || 12),
int($_/60) % 60,
(($_/3600) % 24 < 12) ? 'AM' : 'PM' )
} ( $self->stime, $self->etime );
diff --git a/FS/FS/svc_Common.pm b/FS/FS/svc_Common.pm
index 71290f4..e0f1e33 100644
--- a/FS/FS/svc_Common.pm
+++ b/FS/FS/svc_Common.pm
@@ -1013,6 +1013,42 @@ sub clone_kludge_unsuspend {
shift;
}
+=item find_duplicates MODE FIELDS...
+
+Method used by _check_duplicate routines to find services with duplicate
+values in specified fields. Set MODE to 'global' to search across all
+services, or 'export' to limit to those that share one or more exports
+with this service. FIELDS is a list of field names; only services
+matching in all fields will be returned. Empty fields will be skipped.
+
+=cut
+
+sub find_duplicates {
+ my $self = shift;
+ my $mode = shift;
+ my @fields = @_;
+
+ my %search = map { $_ => $self->getfield($_) }
+ grep { length($self->getfield($_)) } @fields;
+ return () if !%search;
+ my @dup = grep { ! $self->svcnum or $_->svcnum != $self->svcnum }
+ qsearch( $self->table, \%search );
+ return () if !@dup;
+ return @dup if $mode eq 'global';
+ die "incorrect find_duplicates mode '$mode'" if $mode ne 'export';
+
+ my $exports = FS::part_export::export_info($self->table);
+ my %conflict_svcparts;
+ my $part_svc = $self->part_svc;
+ foreach my $part_export ( $part_svc->part_export ) {
+ %conflict_svcparts = map { $_->svcpart => 1 } $part_export->export_svc;
+ }
+ return grep { $conflict_svcparts{$_->cust_svc->svcpart} } @dup;
+}
+
+
+
+
=back
=head1 BUGS
diff --git a/FS/FS/svc_acct.pm b/FS/FS/svc_acct.pm
index 3b26688..94a839b 100644
--- a/FS/FS/svc_acct.pm
+++ b/FS/FS/svc_acct.pm
@@ -9,6 +9,7 @@ use vars qw( $DEBUG $me $conf $skip_fuzzyfiles
$username_ampersand $username_letter $username_letterfirst
$username_noperiod $username_nounderscore $username_nodash
$username_uppercase $username_percent $username_colon
+ $username_slash $username_equals
$password_noampersand $password_noexclamation
$warning_template $warning_from $warning_subject $warning_mimetype
$warning_cc
@@ -47,6 +48,7 @@ use FS::part_export;
use FS::svc_forward;
use FS::svc_www;
use FS::cdr;
+use FS::acct_snarf;
$DEBUG = 0;
$me = '[FS::svc_acct]';
@@ -73,6 +75,8 @@ FS::UID->install_callback( sub {
$username_ampersand = $conf->exists('username-ampersand');
$username_percent = $conf->exists('username-percent');
$username_colon = $conf->exists('username-colon');
+ $username_slash = $conf->exists('username-slash');
+ $username_equals = $conf->exists('username-equals');
$password_noampersand = $conf->exists('password-noexclamation');
$password_noexclamation = $conf->exists('password-noexclamation');
$dirhash = $conf->config('dirhash') || 0;
@@ -100,7 +104,7 @@ FS::UID->install_callback( sub {
);
@saltset = ( 'a'..'z' , 'A'..'Z' , '0'..'9' , '.' , '/' );
-@pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '!', '.', ',' );
+@pw_set = ( 'a'..'z', 'A'..'Z', '0'..'9', '(', ')', '#', '.', ',' );
sub _cache {
my $self = shift;
@@ -440,7 +444,28 @@ sub table_info {
'cgp_addmailtrailer' => { label => 'Add trailer to sent mail',
type => 'checkbox',
},
- #XXX archive messages, mailing lists
+ 'cgp_archiveafter' => {
+ label => 'Archive messages after',
+ type => 'select',
+ select_hash => [
+ -2 => 'default(730 days)',
+ 0 => 'Never',
+ 86400 => '24 hours',
+ 172800 => '2 days',
+ 259200 => '3 days',
+ 432000 => '5 days',
+ 604800 => '7 days',
+ 1209600 => '2 weeks',
+ 2592000 => '30 days',
+ 7776000 => '90 days',
+ 15552000 => '180 days',
+ 31536000 => '365 days',
+ 63072000 => '730 days',
+ ],
+ disable_inventory => 1,
+ disable_select => 1,
+ },
+ #XXX mailing lists
#preferences
'cgp_deletemode' => {
@@ -494,7 +519,6 @@ sub table_info {
},
#mail
- #XXX vacation message, redirect all mail, mail rules
#XXX RPOP settings
},
@@ -1190,6 +1214,7 @@ sub check {
|| $self->ut_enum('cgp_rpopallowed', [ '', 'Y' ])
|| $self->ut_enum('cgp_mailtoall', [ '', 'Y' ])
|| $self->ut_enum('cgp_addmailtrailer', [ '', 'Y' ])
+ || $self->ut_snumbern('cgp_archiveafter')
#preferences
|| $self->ut_alphasn('cgp_deletemode')
|| $self->ut_enum('cgp_emptytrash', $self->cgp_emptytrash_values)
@@ -1198,7 +1223,6 @@ sub check {
|| $self->ut_textn('cgp_skinname')
|| $self->ut_textn('cgp_prontoskinname')
|| $self->ut_alphan('cgp_sendmdnmode')
- #XXX vacation message, redirect all mail, mail rules
#XXX RPOP settings
;
return $error if $error;
@@ -1219,16 +1243,14 @@ sub check {
}
my $ulen = $usernamemax || $self->dbdef_table->column('username')->length;
- if ( $username_uppercase ) {
- $recref->{username} =~ /^([a-z0-9_\-\.\&\%\:]{$usernamemin,$ulen})$/i
- or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
- $recref->{username} = $1;
- } else {
- $recref->{username} =~ /^([a-z0-9_\-\.\&\%\:]{$usernamemin,$ulen})$/
- or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
- $recref->{username} = $1;
- }
+ $recref->{username} =~ /^([a-z0-9_\-\.\&\%\:\/\=]{$usernamemin,$ulen})$/i
+ or return gettext('illegal_username'). " ($usernamemin-$ulen): ". $recref->{username};
+ $recref->{username} = $1;
+
+ unless ( $username_uppercase ) {
+ $recref->{username} =~ /[A-Z]/ and return gettext('illegal_username');
+ }
if ( $username_letterfirst ) {
$recref->{username} =~ /^[a-z]/ or return gettext('illegal_username');
} elsif ( $username_letter ) {
@@ -1252,6 +1274,12 @@ sub check {
unless ( $username_colon ) {
$recref->{username} =~ /\:/ and return gettext('illegal_username');
}
+ unless ( $username_slash ) {
+ $recref->{username} =~ /\// and return gettext('illegal_username');
+ }
+ unless ( $username_equals ) {
+ $recref->{username} =~ /\=/ and return gettext('illegal_username');
+ }
$recref->{popnum} =~ /^(\d*)$/ or return "Illegal popnum: ".$recref->{popnum};
$recref->{popnum} = $1;
@@ -1331,7 +1359,7 @@ sub check {
}
}
$self->getfield('finger') =~
- /^([\w \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
+ /^([µ_0123456789aAáÁàÀâÂåÅäÄãêæÆbBcCçÇdDðÐeEéÉèÈêÊëËfFgGhHiIíÍìÌîÎïÏjJkKlLmMnNñÑoOóÓòÒôÔöÖõÕøغpPqQrRsSßtTuUúÚùÙûÛüÜvVwWxXyYýÝÿzZþÞ \t\!\@\#\$\%\&\(\)\-\+\;\'\"\,\.\?\/\*\<\>]*)$/
or return "Illegal finger: ". $self->getfield('finger');
$self->setfield('finger', $1);
@@ -1909,17 +1937,27 @@ sub email {
=item acct_snarf
Returns an array of FS::acct_snarf records associated with the account.
-If the acct_snarf table does not exist or there are no associated records,
-an empty list is returned
=cut
sub acct_snarf {
my $self = shift;
- return () unless dbdef->table('acct_snarf');
- eval "use FS::acct_snarf;";
- die $@ if $@;
- qsearch('acct_snarf', { 'svcnum' => $self->svcnum } );
+ qsearch({
+ 'table' => 'acct_snarf',
+ 'hashref' => { 'svcnum' => $self->svcnum },
+ #'order_by' => 'ORDER BY priority ASC',
+ });
+}
+
+=item cgp_rpop_hashref
+
+Returns an arrayref of RPOP data suitable for Communigate Pro API commands.
+
+=cut
+
+sub cgp_rpop_hashref {
+ my $self = shift;
+ { map { $_->snarfname => $_->cgp_hashref } $self->acct_snarf };
}
=item decrement_upbytes OCTETS
diff --git a/FS/FS/svc_broadband.pm b/FS/FS/svc_broadband.pm
index 74cedfc..5ffe0e4 100755
--- a/FS/FS/svc_broadband.pm
+++ b/FS/FS/svc_broadband.pm
@@ -113,6 +113,126 @@ sub table { 'svc_broadband'; }
sub table_dupcheck_fields { ( 'mac_addr' ); }
+=item search HASHREF
+
+Class method which returns a qsearch hash expression to search for parameters
+specified in HASHREF.
+
+Parameters:
+
+=over 4
+
+=item unlinked - set to search for all unlinked services. Overrides all other options.
+
+=item agentnum
+
+=item custnum
+
+=item svcpart
+
+=item ip_addr
+
+=item pkgpart - arrayref
+
+=item routernum - arrayref
+
+=item order_by
+
+=back
+
+=cut
+
+sub search {
+ my ($class, $params) = @_;
+ my @where = ();
+ 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 )',
+ );
+
+ # based on FS::svc_acct::search, probably the most mature of the bunch
+ #unlinked
+ push @where, 'pkgnum IS NULL' if $params->{'unlinked'};
+
+ #agentnum
+ if ( $params->{'agentnum'} =~ /^(\d+)$/ and $1 ) {
+ push @where, "agentnum = $1";
+ }
+ push @where, $FS::CurrentUser::CurrentUser->agentnums_sql(
+ 'null_right' => 'View/link unlinked services',
+ 'table' => 'cust_main'
+ );
+
+ #custnum
+ if ( $params->{'custnum'} =~ /^(\d+)$/ and $1 ) {
+ push @where, "custnum = $1";
+ }
+
+ #pkgpart, now properly untainted, can be arrayref
+ for my $pkgpart ( $params->{'pkgpart'} ) {
+ if ( ref $pkgpart ) {
+ my $where = join(',', map { /^(\d+)$/ ? $1 : () } @$pkgpart );
+ push @where, "cust_pkg.pkgpart IN ($where)" if $where;
+ }
+ elsif ( $pkgpart =~ /^(\d+)$/ ) {
+ push @where, "cust_pkg.pkgpart = $1";
+ }
+ }
+
+ #routernum, can be arrayref
+ for my $routernum ( $params->{'routernum'} ) {
+ push @from, 'LEFT JOIN addr_block USING ( blocknum )';
+ if ( ref $routernum and grep { $_ } @$routernum ) {
+ my $where = join(',', map { /^(\d+)$/ ? $1 : () } @$routernum );
+ push @where, "addr_block.routernum IN ($where)" if $where;
+ }
+ elsif ( $routernum =~ /^(\d+)$/ ) {
+ push @where, "addr_block.routernum = $1";
+ }
+ }
+
+ #svcnum
+ if ( $params->{'svcnum'} =~ /^(\d+)$/ ) {
+ push @where, "svcnum = $1";
+ }
+
+ #svcpart
+ if ( $params->{'svcpart'} =~ /^(\d+)$/ ) {
+ push @where, "svcpart = $1";
+ }
+
+ #ip_addr
+ if ( $params->{'ip_addr'} =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ ) {
+ push @where, "ip_addr = '$1'";
+ }
+
+ #custnum
+ if ( $params->{'custnum'} =~ /^(\d+)$/ and $1) {
+ push @where, "custnum = $1";
+ }
+
+ my $addl_from = join(' ', @from);
+ my $extra_sql = '';
+ $extra_sql = 'WHERE '.join(' AND ', @where) if @where;
+ my $count_query = "SELECT COUNT(*) FROM svc_broadband $addl_from $extra_sql";
+ return( {
+ 'table' => 'svc_broadband',
+ 'hashref' => {},
+ 'select' => join(', ',
+ 'svc_broadband.*',
+ 'part_svc.svc',
+ 'cust_main.custnum',
+ FS::UI::Web::cust_sql_fields($params->{'cust_fields'}),
+ ),
+ 'extra_sql' => $extra_sql,
+ 'addl_from' => $addl_from,
+ 'order_by' => "ORDER BY ".($params->{'order_by'} || 'svcnum'),
+ 'count_query' => $count_query,
+ } );
+}
+
=item search_sql STRING
Class method which returns an SQL fragment to search for the given string.
diff --git a/FS/FS/svc_domain.pm b/FS/FS/svc_domain.pm
index 4d85060..dde6d3c 100644
--- a/FS/FS/svc_domain.pm
+++ b/FS/FS/svc_domain.pm
@@ -207,6 +207,27 @@ sub table_info {
label => 'Acct. default Add trailer to sent mail',
type => 'checkbox',
},
+ 'acct_def_cgp_archiveafter' => {
+ label => 'Archive messages after',
+ type => 'select',
+ select_hash => [
+ -2 => 'default(730 days)',
+ 0 => 'Never',
+ 86400 => '24 hours',
+ 172800 => '2 days',
+ 259200 => '3 days',
+ 432000 => '5 days',
+ 604800 => '7 days',
+ 1209600 => '2 weeks',
+ 2592000 => '30 days',
+ 7776000 => '90 days',
+ 15552000 => '180 days',
+ 31536000 => '365 days',
+ 63072000 => '730 days',
+ ],
+ disable_inventory => 1,
+ disable_select => 1,
+ },
'trailer' => {
label => 'Mail trailer',
type => 'textarea',
@@ -282,9 +303,6 @@ defined. An FS::cust_svc record will be created and inserted.
The additional field I<action> should be set to I<N> for new domains, I<M>
for transfers, or I<I> for no action (registered elsewhere).
-A registration or transfer email will be submitted unless
-$FS::svc_domain::whois_hack is true.
-
The additional field I<email> can be used to manually set the admin contact
email address on this email. Otherwise, the svc_acct records for this package
(see L<FS::cust_pkg>) are searched. If there is exactly one svc_acct record
@@ -320,12 +338,36 @@ sub insert {
local $FS::UID::AutoCommit = 0;
my $dbh = dbh;
- $error = $self->SUPER::insert(@_);
+ $error = $self->SUPER::insert(@_)
+ || $self->insert_defaultrecords;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
return $error;
}
+ $dbh->commit or die $dbh->errstr if $oldAutoCommit;
+
+ ''; #no error
+}
+
+=item insert_defaultrecords
+
+=cut
+
+sub insert_defaultrecords {
+ 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;
+
if ( $soamachine ) {
my $soa = new FS::domain_record {
'svcnum' => $self->svcnum,
@@ -335,10 +377,10 @@ sub insert {
'recdata' => "$soamachine $soaemail ( ". time2str("%Y%m%d", time). "00 ".
"$soarefresh $soaretry $soaexpire $soadefaultttl )"
};
- $error = $soa->insert;
+ my $error = $soa->insert;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
- return "couldn't insert SOA record for new domain: $error";
+ return "couldn't insert SOA record: $error";
}
foreach my $record ( @defaultrecords ) {
@@ -353,7 +395,7 @@ sub insert {
my $error = $domain_record->insert;
if ( $error ) {
$dbh->rollback if $oldAutoCommit;
- return "couldn't insert record for new domain: $error";
+ return "couldn't insert record: $error";
}
}
@@ -490,7 +532,7 @@ sub check {
|| $self->ut_enum('acct_def_cgp_rpopallowed', [ '', 'Y' ])
|| $self->ut_enum('acct_def_cgp_mailtoall', [ '', 'Y' ])
|| $self->ut_enum('acct_def_cgp_addmailtrailer', [ '', 'Y' ])
- #XXX archive messages
+ || $self->ut_snumbern('acct_def_cgp_archiveafter')
#preferences
|| $self->ut_alphasn('acct_def_cgp_deletemode')
|| $self->ut_enum('acct_def_cgp_emptytrash',
@@ -501,7 +543,6 @@ sub check {
|| $self->ut_textn('acct_def_cgp_prontoskinname')
|| $self->ut_alphan('acct_def_cgp_sendmdnmode')
#mail
- #XXX rules, archive rule, spam foldering rule(s)
;
return $error if $error;
@@ -521,7 +562,7 @@ sub check {
$recref->{domain} = "$1.$2";
$recref->{suffix} ||= $2;
# hmmmmmmmm.
- } elsif ( $whois_hack && $recref->{domain} =~ /^([\w\-\.]+)\.(\w+)$/ ) {
+ } elsif ( $whois_hack && $recref->{domain} =~ /^([\w\-\.\/]+)\.(\w+)$/ ) {
$recref->{domain} = "$1.$2";
# need to match a list of suffixes - no guarantee they're top-level..
# http://wiki.mozilla.org/TLD_List
@@ -579,6 +620,7 @@ sub domain_record {
'A' => 5,
'TXT' => 6,
'PTR' => 7,
+ 'SRV' => 8,
);
my %sort = (
diff --git a/FS/FS/svc_pbx.pm b/FS/FS/svc_pbx.pm
index d2760a5..0eb5443 100644
--- a/FS/FS/svc_pbx.pm
+++ b/FS/FS/svc_pbx.pm
@@ -253,22 +253,27 @@ sub check {
$self->SUPER::check;
}
-#XXX this is a way-too simplistic implementation
-# at the very least, title should be unique across exports that need that or
-# controlled by a conf setting or something
sub _check_duplicate {
my $self = shift;
my $conf = new FS::Conf;
- return '' if $conf->config('global_unique-pbx_title') eq 'disabled';
-
+
$self->lock_table;
- if ( qsearchs( 'svc_pbx', { 'title' => $self->title } ) ) {
- return "Name in use";
- } else {
- return '';
+ foreach my $field ('title', 'id') {
+ my $global_unique = $conf->config("global_unique-pbx_$field");
+ # can be 'disabled', 'enabled', or empty.
+ # if empty, check per exports; if not empty or disabled, check
+ # globally.
+ next if $global_unique eq 'disabled';
+ my @dup = $self->find_duplicates(
+ ($global_unique ? 'global' : 'export') , $field
+ );
+ next if !@dup;
+ return "duplicate $field '".$self->getfield($field).
+ "': conflicts with svcnum ".$dup[0]->svcnum;
}
+ return '';
}
=item get_cdrs
@@ -292,6 +297,9 @@ with the chosen prefix.
=item disable_src => 1: No-op for svc_pbx CDR processing.
+=item by_svcnum => 1: Select CDRs where the svcnum field matches, instead of
+title/charged_party. Normally this field is set after processing.
+
=back
=cut
@@ -307,18 +315,24 @@ sub get_cdrs {
my $for_update = $options{'for_update'} ? 'FOR UPDATE' : '';
- my $title = $self->title;
+ if ( $options{'by_svcnum'} ) {
+ $hash{'svcnum'} = $self->svcnum;
+ }
+ else {
+ #matching by title
+ my $title = $self->title;
- my $prefix = $options{'default_prefix'};
+ my $prefix = $options{'default_prefix'};
- my @orwhere = map " $_ = '$title' ", @fields;
- push @orwhere, map " $_ = '$prefix$title' ", @fields
- if length($prefix);
- if ( $prefix =~ /^\+(\d+)$/ ) {
- push @orwhere, map " $_ = '$1$title' ", @fields
- }
+ my @orwhere = map " $_ = '$title' ", @fields;
+ push @orwhere, map " $_ = '$prefix$title' ", @fields
+ if length($prefix);
+ if ( $prefix =~ /^\+(\d+)$/ ) {
+ push @orwhere, map " $_ = '$1$title' ", @fields
+ }
- push @where, ' ( '. join(' OR ', @orwhere ). ' ) ';
+ push @where, ' ( '. join(' OR ', @orwhere ). ' ) ';
+ }
if ( $options{'begin'} ) {
push @where, 'startdate >= '. $options{'begin'};
@@ -327,7 +341,8 @@ sub get_cdrs {
push @where, 'startdate < '. $options{'end'};
}
- my $extra_sql = ( keys(%hash) ? ' AND ' : ' WHERE ' ). join(' AND ', @where );
+ my $extra_sql = ( keys(%hash) ? ' AND ' : ' WHERE ' ). join(' AND ', @where )
+ if @where;
my @cdrs =
qsearch( {
diff --git a/FS/FS/svc_phone.pm b/FS/FS/svc_phone.pm
index a0a2c3e..a599ea2 100644
--- a/FS/FS/svc_phone.pm
+++ b/FS/FS/svc_phone.pm
@@ -547,6 +547,8 @@ with the chosen prefix.
=item disable_src => 1: Only match on "charged_party", not "src".
+=item by_svcnum: not supported for svc_phone
+
=back
=cut
diff --git a/FS/MANIFEST b/FS/MANIFEST
index 1b2e08d..56f7af0 100644
--- a/FS/MANIFEST
+++ b/FS/MANIFEST
@@ -68,7 +68,12 @@ FS/cust_bill_pkg_detail.pm
FS/cust_credit.pm
FS/cust_credit_bill.pm
FS/cust_main.pm
+FS/cust_main/Billing.pm
+FS/cust_main/Billing_Realtime.pm
FS/cust_main/Import.pm
+FS/cust_main/Packages.pm
+FS/cust_main/Search.pm
+FS/cust_main/_Marketgear.pm
FS/cust_main_Mixin.pm
FS/cust_main_county.pm
FS/cust_main_invoice.pm
@@ -528,3 +533,7 @@ FS/part_tag.pm
t/part_tag.t
FS/svc_CGP_Mixin.pm
FS/svc_CGPRule_Mixin.pm
+FS/svc_cert.pm
+t/svc_cert.t
+FS/part_pkg_discount.pm
+t/part_pkg_discount.t
diff --git a/FS/bin/freeside-daily b/FS/bin/freeside-daily
index e16cc5c..6a542c7 100755
--- a/FS/bin/freeside-daily
+++ b/FS/bin/freeside-daily
@@ -99,7 +99,7 @@ the bill and collect methods of a cust_main object. See L<FS::cust_main>.
with today's date, irregardless of the pretend date used to pre-generate
the invoices.
- -p: Only process customers with the specified payby (I<CARD>, I<DCRD>, I<CHEK>, I<DCHK>, I<BILL>, I<COMP>, I<LECB>)
+ -p: Only process customers with the specified payby (CARD, DCRD, CHEK, DCHK, BILL, COMP, LECB)
-a: Only process customers with the specified agentnum. Multiple agentnums can be specified, separated with commas.
diff --git a/FS/bin/freeside-monthly b/FS/bin/freeside-monthly
index a81e3e9..0d6ea14 100755
--- a/FS/bin/freeside-monthly
+++ b/FS/bin/freeside-monthly
@@ -64,7 +64,7 @@ the bill and collect methods of a cust_main object. See L<FS::cust_main>.
"pretend date" 15 days from whatever was specified by the -d switch
(or now, if no -d switch was given).
- -p: Only process customers with the specified payby (I<CARD>, I<DCRD>, I<CHEK>, I<DCHK>, I<BILL>, I<COMP>, I<LECB>)
+ -p: Only process customers with the specified payby (CARD, DCRD, CHEK, DCHK, BILL, COMP, LECB)
-a: Only process customers with the specified agentnum
diff --git a/FS/bin/freeside-prepaidd b/FS/bin/freeside-prepaidd
index 86bfe87..05b068b 100644
--- a/FS/bin/freeside-prepaidd
+++ b/FS/bin/freeside-prepaidd
@@ -41,21 +41,30 @@ while (1) {
my $work_cust_pkg = $cust_pkg;
my $cust_main = $cust_pkg->cust_main;
+
+ #insurance: somehow winding up here without things properly applied...
+ my $a_error = $cust_main->apply_payments_and_credits;
+ if ( $a_error ) {
+ warn "Error applying payments&credits, customer #". $cust_main->custnum;
+ next;
+ }
+
if ( $cust_main->total_unapplied_payments > 0
- or $cust_main->total_credited > 0
+ || $cust_main->total_unapplied_credits > 0
)
{
+
#this needs a flag to say only do the prepaid packages...
# and only try em if the renewal price matches.. but this will do for now
my $b_error = $cust_main->bill;
if ( $b_error ) {
warn "Error billing customer #". $cust_main->custnum;
- next;
+ next;
}
$b_error = $cust_main->apply_payments_and_credits;
if ( $b_error ) {
warn "Error applying payments&credits, customer #". $cust_main->custnum;
- next;
+ next;
}
$work_cust_pkg = qsearchs('cust_pkg', { 'pkgnum' => $work_cust_pkg->pkgnum } );
diff --git a/FS/bin/freeside-queued b/FS/bin/freeside-queued
index c9b0edb..756b699 100644
--- a/FS/bin/freeside-queued
+++ b/FS/bin/freeside-queued
@@ -186,7 +186,7 @@ while (1) {
dbh->{'private_profile'} = {} if UNIVERSAL::can(dbh, 'sprintProfile');
#auto-use classes...
- if ( $ljob->job =~ /(FS::(part_export|cust_main)::\w+)::/
+ if ( $ljob->job =~ /(FS::(part_export|cust_main|cust_pkg)::\w+)::/
|| $ljob->job =~ /(FS::\w+)::/
)
{
diff --git a/FS/bin/freeside-radgroup b/FS/bin/freeside-radgroup
index ed85626..3326329 100644
--- a/FS/bin/freeside-radgroup
+++ b/FS/bin/freeside-radgroup
@@ -52,13 +52,13 @@ freeside-radgroup - Command line utility to manipulate radius groups
=head1 DESCRIPTION
- B<user> is a freeside user as added with freeside-adduser.
+B<user> is a freeside user as added with freeside-adduser.
- B<command> is the action to take. Available actions are: I<add>
+B<command> is the action to take. Available actions are: I<add>
- B<groupname> is the group to add (or remove, etc.)
+B<groupname> is the group to add (or remove, etc.)
- B<svcpart> specifies which accounts will be updated.
+B<svcpart> specifies which accounts will be updated.
=head1 EXAMPLES
diff --git a/FS/bin/freeside-selfservice-xmlrpcd b/FS/bin/freeside-selfservice-xmlrpcd
index fa745ec..e50d516 100755
--- a/FS/bin/freeside-selfservice-xmlrpcd
+++ b/FS/bin/freeside-selfservice-xmlrpcd
@@ -63,6 +63,8 @@ logfile("$FREESIDE_LOG/selfservice-xmlrpcd.log");
daemonize2();
+FS::ClientAPI::Signup::clear_cache();
+
my $conf = new FS::Conf;
die "not running; selfservice-xmlrpc conf option is off\n"
diff --git a/FS/bin/freeside-upgrade b/FS/bin/freeside-upgrade
index 4a6fac2..aca545b 100755
--- a/FS/bin/freeside-upgrade
+++ b/FS/bin/freeside-upgrade
@@ -11,7 +11,7 @@ use FS::Schema qw( dbdef dbdef_dist reload_dbdef );
use FS::Misc::prune qw(prune_applications);
use FS::Conf;
use FS::Record qw(qsearch);
-use FS::Upgrade qw(upgrade upgrade_sqlradius);
+use FS::Upgrade qw(upgrade_schema upgrade_config upgrade upgrade_sqlradius);
my $start = time;
@@ -71,6 +71,21 @@ if ( dbdef->table('cgp_rule_condition') &&
}
+# RT required field flag
+# for consistency with RT schema: mysql is in CamelCase,
+# pg is in lowercase, and they use different data types.
+my ($t, $creq, $cdis) =
+ map { driver_name =~ /^mysql/i ? $_ : lc($_) }
+ ('CustomFields','Required','Disabled');
+
+if ( dbdef->table($t) &&
+ ! dbdef->table($t)->column($creq) ) {
+ push @bugfix,
+ "ALTER TABLE $t ADD COLUMN $creq ".
+ dbdef->table($t)->column($cdis)->type .
+ ' NOT NULL DEFAULT 0';
+}
+
if ( $DRY_RUN ) {
print
join(";\n", @bugfix ). ";\n";
@@ -82,6 +97,8 @@ if ( $DRY_RUN ) {
or die "Error: ". $dbh->errstr. "\n executing: $statement";
}
+ upgrade_schema();
+
dbdef_create($dbh, $dbdef_file);
delete $FS::Schema::dbdef_cache{$dbdef_file}; #force an actual reload
reload_dbdef($dbdef_file);
@@ -197,6 +214,14 @@ $dbh = adminsuidsetup($user);
warn "Re-initialization with updated schema completed in ". (time-$start). " seconds\n"; # if $DEBUG;
$start = time;
+upgrade_config()
+ unless $DRY_RUN || $opt_s;
+
+$dbh->commit or die $dbh->errstr;
+
+warn "Config updates completed in ". (time-$start). " seconds\n"; # if $DEBUG;
+$start = time;
+
upgrade()
unless $DRY_RUN || $opt_s;
diff --git a/FS/bin/freeside-wipe-cvv b/FS/bin/freeside-wipe-cvv
new file mode 100755
index 0000000..70f0df9
--- /dev/null
+++ b/FS/bin/freeside-wipe-cvv
@@ -0,0 +1,87 @@
+#!/usr/bin/perl -w
+
+use strict;
+use Getopt::Std;
+use FS::UID qw(adminsuidsetup dbh);
+use FS::Record qw(qsearch qsearchs);
+use Time::Local 'timelocal';
+use Date::Format 'time2str';
+
+my %opt;
+getopts('vnd:', \%opt);
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+$FS::UID::AutoCommit = 0;
+$FS::Record::nowarn_identical = 1;
+
+my $extra_sql = FS::cust_main->cancel_sql;
+$extra_sql = "WHERE $extra_sql
+AND cust_main.payby IN('CARD','DCRD','CHEK','DCHK')
+";
+
+if($opt{'d'}) {
+ $opt{'d'} =~ /^(\d+)$/ or die &usage;
+ my $time = timelocal(0,0,0,(localtime(time-(86400*$1)))[3..5]);
+ print "Excluding customers canceled after ".time2str("%D",$time)."\n"
+ if $opt{'v'};
+ $extra_sql .= ' AND 0 = (' . FS::cust_main->select_count_pkgs_sql .
+ " AND cust_pkg.cancel > $time)";
+}
+
+foreach my $cust_main ( qsearch({
+ 'table' => 'cust_main',
+ 'hashref' => {},
+ 'extra_sql' => $extra_sql
+ }) ) {
+ if($opt{'v'}) {
+ print $cust_main->name, "\n";
+ }
+ if($opt{'n'}) {
+ $cust_main->payinfo('');
+ $cust_main->paydate('');
+ $cust_main->payby('BILL');
+# can't have a CARD or CHEK without a valid payinfo
+ }
+ $cust_main->paycvv('');
+ my $error = $cust_main->replace;
+ if($error) {
+ dbh->rollback;
+ die "$error (changes reverted)\n";
+ }
+}
+dbh->commit;
+
+sub usage {
+ "Usage:\n\n freeside-wipe-cvv [ -v ] [ -n ] [ -d days ] user\n"
+}
+
+=head1 NAME
+
+freeside-wipe-cvv - Wipe sensitive payment information from customer records.
+
+=head1 SYNOPSIS
+
+ freeside-wipe-cvv [ -v ] [ -n ] [ -d days ] user
+
+=head1 DESCRIPTION
+
+freeside-wipe-cvv deletes the CVV numbers (and, optionally, credit
+card or bank account numbers) of customers who have no non-canceled
+packages. Normally CVV numbers are deleted as soon as a payment is
+processed; if the customer is canceled before a payment is processed,
+this may not happen and the CVV will remain indefinitely, violating
+good security practice and (possibly) your merchant agreement.
+Running freeside-wipe-cvv will remove this data.
+
+-v: Be verbose.
+
+-n: Remove card and account numbers in addition to CVV numbers. This
+will also set the customer's payment method to 'BILL'.
+
+-d days: Only remove CVV/card numbers from customers who have been
+inactive for at least that many days. Optional; will default to
+all canceled customers.
+
+=cut
+
diff --git a/FS/t/part_pkg_discount.t b/FS/t/part_pkg_discount.t
new file mode 100644
index 0000000..0e408d0
--- /dev/null
+++ b/FS/t/part_pkg_discount.t
@@ -0,0 +1,5 @@
+BEGIN { $| = 1; print "1..1\n" }
+END {print "not ok 1\n" unless $loaded;}
+use FS::part_pkg_discount;
+$loaded=1;
+print "ok 1\n";
diff --git a/Makefile b/Makefile
index bf3f3d7..b0d43ea 100644
--- a/Makefile
+++ b/Makefile
@@ -123,8 +123,8 @@ RT_PATH = /opt/rt3
FREESIDE_PATH = `pwd`
PERL_INC_DEV_KLUDGE = /usr/local/share/perl/5.10.1/
-VERSION=2.1.1cvs
-TAG=freeside_2_1_1
+VERSION=2.1.2cvs
+TAG=freeside_2_1_2
DEBVERSION = `echo ${VERSION} | perl -pe 's/(\d)([a-z])/\1~\2/'`-1
@@ -210,7 +210,7 @@ perl-modules:
" blib/lib/FS/part_export/*.pm;\
perl -p -i -e "\
s|%%%FREESIDE_CACHE%%%|${FREESIDE_CACHE}|g;\
- " blib/lib/FS/cust_main/*.pm;\
+ " blib/lib/FS/cust_main/*.pm blib/lib/FS/cust_pkg/*.pm;\
perl -p -i -e "\
s|%%%FREESIDE_CONF%%%|${FREESIDE_CONF}|g;\
s|%%%FREESIDE_LOG%%%|${FREESIDE_LOG}|g;\
diff --git a/bin/19add b/bin/19add
new file mode 100755
index 0000000..726cd66
--- /dev/null
+++ b/bin/19add
@@ -0,0 +1,20 @@
+#!/usr/bin/perl
+
+use Cwd;
+use String::ShellQuote;
+
+my $USER = $ENV{USER};
+
+my $dir = getcwd;
+( my $prefix = $dir ) =~ s(^/home/$USER/freeside/?)() or die $dir; #eventually from anywhere
+
+system join('',
+ #"cvs add @ARGV && ",
+ "cvs add @ARGV ; ",
+ "( for file in @ARGV; do ",
+ "cp -i \$file /home/$USER/freeside1.9/$prefix/`dirname \$file`;",
+ "done ) && ",
+ "cd /home/$USER/freeside1.9/$prefix/ && ",
+ "cvs add @ARGV"
+);
+
diff --git a/bin/19commit b/bin/19commit
new file mode 100755
index 0000000..0b4cd05
--- /dev/null
+++ b/bin/19commit
@@ -0,0 +1,26 @@
+#!/usr/bin/perl
+
+# usage: 19commit 'log message' filename filename ...
+
+use Cwd;
+use String::ShellQuote;
+
+my $USER = $ENV{USER};
+
+my $dir = getcwd;
+( my $prefix = $dir ) =~ s(^/home/$USER/freeside/?)() or die $dir; #eventually from anywhere
+
+my $desc = shell_quote(shift @ARGV); # -m
+
+die "no files!" unless @ARGV;
+
+#warn "$prefix";
+
+#print <<END;
+system join('',
+ "( cd /home/$USER/freeside1.9/$prefix; cvs update @ARGV ) && ",
+ "cvs diff -u @ARGV | ( cd /home/$USER/freeside1.9/$prefix; patch -p0 ) ",
+ " && ( ( cvs commit -m $desc @ARGV & ); ",
+ "( sleep 1;cd /home/$USER/freeside1.9/$prefix; cvs commit -m $desc @ARGV & ) )"
+);
+
diff --git a/bin/19diff b/bin/19diff
new file mode 100755
index 0000000..dcc5165
--- /dev/null
+++ b/bin/19diff
@@ -0,0 +1,12 @@
+#!/usr/bin/perl
+
+my $file = shift;
+
+chomp(my $dir = `pwd`);
+$dir =~ s/freeside\//freeside1.9\//;
+
+#$cmd = "diff -u $file $dir/$file";
+$cmd = "diff -u $dir/$file $file";
+print "$cmd\n";
+system($cmd);
+
diff --git a/bin/cdr-mysql.import b/bin/cdr-mysql.import
new file mode 100755
index 0000000..608a8dc
--- /dev/null
+++ b/bin/cdr-mysql.import
@@ -0,0 +1,88 @@
+#!/usr/bin/perl
+
+use strict;
+use vars qw( $DEBUG );
+use Date::Parse 'str2time';
+use Date::Format 'time2str';
+use FS::UID qw(adminsuidsetup dbh);
+use FS::cdr;
+use DBI;
+use Getopt::Std;
+
+my %opt;
+getopts('H:U:P:D:T:', \%opt);
+my $user = shift or die &usage;
+
+my $dsn = 'dbi:mysql';
+$dsn .= ":database=$opt{D}" if $opt{D};
+$dsn .= ":host=$opt{H}" if $opt{H};
+
+my $mysql = DBI->connect($dsn, $opt{U}, $opt{P})
+ or die $DBI::errstr;
+
+adminsuidsetup $user;
+
+my $fsdbh = FS::UID::dbh;
+
+# check for existence of freesidestatus
+my $table = $opt{T} || 'cdr';
+my $status = $mysql->selectall_arrayref("SHOW COLUMNS FROM $table WHERE Field = 'freesidestatus'");
+if( ! @$status ) {
+ print "Adding freesidestatus column...\n";
+ $mysql->do("ALTER TABLE $table ADD COLUMN freesidestatus varchar(32)")
+ or die $mysql->errstr;
+}
+else {
+ print "freesidestatus column present\n";
+}
+
+my @cols = ( qw(
+calldate clid src dst dcontext channel lastapp lastdata duration
+ billsec disposition amaflags accountcode uniqueid userfield) );
+my $sql = 'SELECT '.join(',', @cols). " FROM $table WHERE freesidestatus IS NULL";
+my $sth = $mysql->prepare($sql);
+$sth->execute;
+print "Importing ".$sth->rows." records...\n";
+
+my $cdr_batch = new FS::cdr_batch({
+ 'cdrbatch' => 'mysql-import-'. time2str('%Y/%m/%d-%T',time),
+ });
+my $error = $cdr_batch->insert;
+die $error if $error;
+my $cdrbatchnum = $cdr_batch->cdrbatchnum;
+my $imports = 0;
+my $updates = 0;
+
+my $row;
+while ( $row = $sth->fetchrow_hashref ) {
+ my $cdr = FS::cdr->new($row);
+ $cdr->startdate(str2time($cdr->calldate));
+ $cdr->cdrbatchnum($cdrbatchnum);
+ my $error = $cdr->insert;
+ if($error) {
+ print "failed import: $error\n";
+ }
+ else {
+ $imports++;
+ if( $mysql->do("UPDATE cdr SET freesidestatus = 'done'
+ WHERE calldate = ? AND src = ? AND dst = ?",
+ undef,
+ $row->{'calldate'},
+ $row->{'src'},
+ $row->{'dst'},
+
+ ) ) {
+ $updates++;
+ }
+ else {
+ print "failed to set status: ".$mysql->errstr."\n";
+ }
+ }
+}
+print "Done.\nImported $imports CDRs, marked $updates CDRs as done.\n";
+$mysql->disconnect;
+
+sub usage {
+ "Usage: \n cdr-mysql.import\n\t[ -H host ]\n\t-D database\n\t-U user\n\t-P password\n\tfreesideuser\n";
+}
+
diff --git a/bin/cust_main-find_bogus_geocode b/bin/cust_main-find_bogus_geocode
new file mode 100755
index 0000000..04a38a9
--- /dev/null
+++ b/bin/cust_main-find_bogus_geocode
@@ -0,0 +1,36 @@
+#!/usr/bin/perl -w
+
+use strict;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw( qsearch );
+use FS::cust_main;
+
+my $user = shift or die "usage: cust_main-find_bogus_geocode username\n";
+adminsuidsetup $user;
+
+my @cust_main = qsearch({
+ 'table' => 'cust_main',
+ 'extra_sql' => 'WHERE geocode IS NOT NULL',
+});
+
+foreach my $cust_main ( @cust_main ) {
+
+ my $db_geocode = $cust_main->geocode;
+
+ $cust_main->set('geocode', '');
+
+ my $calc_geocode = $cust_main->geocode('cch');
+
+ next unless $calc_geocode;
+
+ my $cust = $cust_main->custnum.': '. $cust_main->name. "\n";
+
+ if ( $db_geocode eq $calc_geocode ) {
+ warn "unnecessary geocode override for $cust";
+ } else {
+ warn "bogus geocode override $db_geocode overrides $calc_geocode for $cust";
+ }
+
+}
+
+1;
diff --git a/bin/freeside-backup b/bin/freeside-backup
new file mode 100644
index 0000000..97a4899
--- /dev/null
+++ b/bin/freeside-backup
@@ -0,0 +1,42 @@
+#!/usr/bin/perl -w
+
+use strict;
+use Getopt::Std;
+use FS::UID qw(adminsuidsetup);
+use FS::Conf;
+
+my $user = shift or die &usage;
+adminsuidsetup $user;
+
+#you can skip this just by not having the config
+use FS::Cron::backup qw(backup_scp);
+backup_scp();
+
+sub usage {
+ die "Usage:\n\n freeside-backup user\n";
+}
+
+###
+# documentation
+###
+
+=head1 NAME
+
+freeside-backup - Runs a backup
+
+=head1 SYNOPSIS
+
+ freeside-backup user
+
+=head1 DESCRIPTION
+
+Runs a backup. See the dump-scpdest configuration option.
+
+=head1 BUGS
+
+=head1 SEE ALSO
+
+=cut
+
+1;
+
diff --git a/bin/generate-table-module b/bin/generate-table-module
index 16a23e5..e7fc992 100755
--- a/bin/generate-table-module
+++ b/bin/generate-table-module
@@ -1,7 +1,11 @@
#!/usr/bin/perl
+use strict;
+use vars qw( $opt_n );
use FS::Schema qw( dbdef_dist );
+use Getopt::Std;
+getopts('n');
my $table = shift;
###
@@ -69,12 +73,9 @@ close DEST;
# add to FS/FS/Mason.pm
###
-# it needs to be:
-# 1. disablable (have an option to turn it off)
-# 2. documented in the schema change docs
-#
-#my $magic = '# Sammath Naur';
-#system("perl -pi -e 's/$magic/use FS::$table;\n $magic/' FS/FS/Mason.pm");
+my $magic = '# Sammath Naur';
+system("perl -pi -e 's/$magic/use FS::$table;\n $magic/' FS/FS/Mason.pm")
+ unless $opt_n;
###
# add FS/t/table.t
diff --git a/bin/merge-referrals b/bin/merge-referrals
new file mode 100644
index 0000000..ba07a81
--- /dev/null
+++ b/bin/merge-referrals
@@ -0,0 +1,20 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearchs);
+use FS::cust_main;
+
+my $user = shift or die "usage: merge-customers username custnum\n";
+adminsuidsetup $user;
+
+my $custnum = shift or die "usage: merge-customers username custnum\n";
+
+foreach my $cust_main (
+ qsearch('cust_main', { 'referral_custnum' => $custnum })
+) {
+ my $error = $cust_main->merge($custnum);
+ die $error if $error;
+}
+
+1;
diff --git a/bin/opensrs_domain_pkgs b/bin/opensrs_domain_pkgs
index ae14761..2420095 100755
--- a/bin/opensrs_domain_pkgs
+++ b/bin/opensrs_domain_pkgs
@@ -73,7 +73,23 @@ foreach my $svc_domain ( $part_export->svc_x ) {
second => $second,
time_zone => 'America/New_York',#timezone of opensrs
);
- my $expiretime = $exp->epoch;
+ #my $expiretime = $exp->epoch;
+
+ #set the bill date early enough to allow a couple chances to pay
+ $month--;
+ if ($month < 1) {
+ $year--;
+ $month=12;
+ }
+ my $bill = DateTime->new( year => $year,
+ month => $month,
+ day => 1,
+ hour => 0,
+ minute => 0,
+ second => 0,
+ time_zone => 'America/Chicago',#timezone of customer
+ );
+ my $expiretime = $bill->epoch;
my $error = $part_export->is_supported_domain($svc_domain);
warn $error if $error;
diff --git a/bin/rt-trim-whitespace b/bin/rt-trim-whitespace
new file mode 100755
index 0000000..503d9cf
--- /dev/null
+++ b/bin/rt-trim-whitespace
@@ -0,0 +1,38 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use FS::Record;
+use FS::UID qw(adminsuidsetup dbh driver_name);
+
+# Remove trailing whitespace from custom field option lists and values.
+
+my $dbh = adminsuidsetup(shift) or die "Usage: rt-trim-whitespace username\n";
+die "rt-trim-whitespace only works on Pg databases" if driver_name ne 'Pg';
+
+my @updates = (
+ customfieldvalues => 'name',
+ objectcustomfieldvalues => 'content',
+);
+
+while(@updates) {
+ my $table = shift @updates;
+ my $field = shift @updates;
+ my $select =
+"SELECT $field FROM $table WHERE $field != substring($field from ".
+ q!E'^(.*\\\\S)\\\\s*$'! . ')';
+
+ print "$select\n";
+ my $rows = $dbh->do($select);
+ print "$rows rows found.\n";
+
+ if($rows) {
+ my $update =
+"UPDATE $table SET $field = substring($field from ".q!E'^(.*\\\\S)\\\\s*$'!.')'.
+" WHERE $field != substring($field from ".q!E'^(.*\\\\S)\\\\s*$'!.')';
+ print "$update\n";
+ my $rows = $dbh->do($update);
+ print "$rows updated.\n";
+ }
+}
+$dbh->commit or die $dbh->errstr;
diff --git a/bin/select-cust-desync_bill_dates.sql b/bin/select-cust-desync_bill_dates.sql
new file mode 100644
index 0000000..5506f90
--- /dev/null
+++ b/bin/select-cust-desync_bill_dates.sql
@@ -0,0 +1,9 @@
+SELECT DISTINCT custnum, agent_custid, first, last, company
+ FROM cust_pkg LEFT JOIN cust_main USING ( custnum )
+ WHERE cancel IS NULL AND 0 < (
+ SELECT COUNT(*) FROM cust_pkg AS others
+ WHERE cust_pkg.custnum = others.custnum
+ AND cust_pkg.pkgnum != others.pkgnum
+ AND cust_pkg.bill != others.bill
+ AND others.cancel IS NULL
+ );
diff --git a/bin/test_scrub b/bin/test_scrub
index 5766925..88edc33 100644
--- a/bin/test_scrub
+++ b/bin/test_scrub
@@ -12,8 +12,13 @@
#-business-onlinepayment and business-onlinepayment-ach config
use strict;
+use vars qw( $opt_h );
+use Getopt::Std;
use FS::UID qw(adminsuidsetup dbh);
use FS::Conf;
+use FS::Schema qw(dbdef);
+
+getopts('h');
adminsuidsetup shift;
@@ -45,4 +50,11 @@ foreach my $item (qw(
$conf->delete($item);
}
+if ($opt_h) { # not all history can be safely deleted
+ foreach my $table (grep { /^h_\w+$/ } dbdef->tables) {
+ my $sth = dbh->prepare("DELETE FROM $table") or die dbh->errstr;
+ $sth->execute or die $sth->errstr;
+ }
+}
+
dbh->commit or die dbh->errstr;
diff --git a/bin/test_scrub_sql b/bin/test_scrub_sql
new file mode 100755
index 0000000..fb26fe9
--- /dev/null
+++ b/bin/test_scrub_sql
@@ -0,0 +1,58 @@
+#!/usr/bin/perl -w
+
+#This drops anything from the database that could cause live things to happen.
+#You'd want to do this on a test copy of your live database but NEVER on the
+#live database itself.
+
+#-all exports (all records in part_export, part_export_option export_svc)
+#-all non-POST invoice destinations (cust_main_invoice)
+#-all payment gateways and agent payment gw overrides (payment_gateway,
+# payment_gateway_option, agent_payment_gateway)
+#-everything in the job queue (queue and queue_arg)
+#-business-onlinepayment and business-onlinepayment-ach config
+#AND
+#-masks all payment info
+
+foreach my $table (qw(
+ part_export_option
+ payment_gateway
+ payment_gateway_option
+ agent_payment_gateway
+ queue
+ queue_arg
+ cust_pay_batch
+)) {
+ print "DELETE FROM $table;\n";
+ print "DELETE FROM h_$table;\n";
+}
+
+foreach my $table (qw(
+ part_export
+ export_svc
+)) {
+ print "DELETE FROM $table;\n";
+}
+
+print "DELETE FROM cust_main_invoice WHERE dest != 'POST';\n";
+
+foreach my $item (qw(
+ business-onlinepayment
+ business-onlinepayment-ach
+)) {
+ print "DELETE FROM conf WHERE name = '$item';\n";
+ print "DELETE FROM h_conf WHERE name = '$item';\n";
+}
+
+my @ptables = map { ($_, "h_$_") } qw(
+ cust_main
+ cust_pay
+ cust_pay_pending
+ cust_pay_void
+ cust_refund
+);
+foreach my $table (@ptables) {
+ print "UPDATE $table SET payinfo = paymask WHERE payby IN ( 'CARD','DCRD','CHEK','DCHK' );\n";
+}
+
+print "UPDATE cust_main set paycvv = NULL;\n";
+print "UPDATE h_cust_main set paycvv = NULL;\n";
diff --git a/bin/wipe-customers b/bin/wipe-customers
new file mode 100644
index 0000000..e65ed61
--- /dev/null
+++ b/bin/wipe-customers
@@ -0,0 +1,30 @@
+#!/usr/bin/perl
+
+use strict;
+use FS::UID qw(adminsuidsetup);
+use FS::Record qw(qsearch);
+use FS::cust_main;
+
+die "this removes all customers in your database except for customer 1 - remove this line to enable";
+
+my $user = shift or die "usage: wipe-customers username\n";
+adminsuidsetup $user;
+
+#this isn't terribly efficient, but the idea was clearing out a test database,
+#not actually destroying a large amount of data
+
+foreach my $cust_main (
+
+ qsearch('cust_main', { 'custnum' => { op=>'!=', value=>'1' } } )
+
+) {
+
+ my @cerrors = $cust_main->cancel( quiet=>1, nobill=>1 );
+ if ( @cerrors ) {
+ die join(' / ', @cerrors);
+ }
+
+ my $error = $cust_main->delete( 'delete_financials' => 1);
+ die $error if $error;
+
+}
diff --git a/bin/xmlrpc-customer_status.pl b/bin/xmlrpc-customer_status.pl
new file mode 100755
index 0000000..3840b20
--- /dev/null
+++ b/bin/xmlrpc-customer_status.pl
@@ -0,0 +1,23 @@
+#!/usr/bin/perl
+#
+# xmlrpc-customer_status.pl username password custnum
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+my( $u, $p, $custnum ) = ( @ARGV );
+my $userinfo = $u.':'.$p;
+
+my $uri = new URI 'http://localhost/freeside/misc/xmlrpc.cgi';
+$uri->userinfo( $userinfo );
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $result = $server->call('Maestro.customer_status', $custnum );
+
+#die $result->{'error'} if $result->{'error'};
+
+print Dumper($result);
+
+1;
diff --git a/bin/xmlrpc-order_pkg.pl b/bin/xmlrpc-order_pkg.pl
new file mode 100755
index 0000000..90d1ff3
--- /dev/null
+++ b/bin/xmlrpc-order_pkg.pl
@@ -0,0 +1,31 @@
+#!/usr/bin/perl
+#
+# xmlrpc-order_pkg.pl username password
+
+use strict;
+use Frontier::Client;
+use Data::Dumper;
+
+my( $u, $p, $custnum ) = ( @ARGV );
+my $userinfo = $u.':'.$p;
+
+my $uri = new URI 'http://localhost/freeside/misc/xmlrpc.cgi';
+$uri->userinfo( $userinfo );
+
+my $server = new Frontier::Client ( 'url' => $uri );
+
+my $result = $server->call('Maestro.order_pkg',
+ {
+ 'custnum' => 8,
+ 'pkgpart' => 3,
+ 'id' => $$, #unique
+ 'title' => 'John Q. Public', #'name' also works
+ #(turn off global_unique-pbx_title)
+ },
+);
+
+#die $result->{'error'} if $result->{'error'};
+
+print Dumper($result);
+
+1;
diff --git a/conf/invoice_latex_statement b/conf/invoice_latex_statement
index 302306a..98d4f70 100644
--- a/conf/invoice_latex_statement
+++ b/conf/invoice_latex_statement
@@ -86,7 +86,7 @@
\returninset
\makebox{
\begin{tabular}{ll}
- \includegraphics{[@-- $conf_dir --@]/logo.eps} &
+ \includegraphics{[@-- $logo_file --@]} &
\begin{minipage}[b]{5.5cm}
[@-- $returnaddress --@]
\end{minipage}
diff --git a/conf/invoice_print_pdf b/conf/invoice_print_pdf
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/conf/invoice_print_pdf
diff --git a/conf/svc_acct-disable_access_number b/conf/svc_acct-disable_access_number
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/conf/svc_acct-disable_access_number
diff --git a/conf/welcome_letter b/conf/welcome_letter
index be7b484..b95a715 100644
--- a/conf/welcome_letter
+++ b/conf/welcome_letter
@@ -67,7 +67,7 @@
\returninset
\makebox{
\begin{tabular}{ll}
- \includegraphics{[@-- $conf_dir --@]/logo.eps} &
+ \includegraphics{[@-- $logo_file --@]} &
\begin{minipage}[b]{5.5cm}
[@-- $returnaddress --@]
\end{minipage}
@@ -75,7 +75,7 @@
}
}
{ % ... pages
- %\includegraphics{[@-- $conf_dir --@]/logo.eps} % Uncomment if you want the logo on all pages.
+ %\includegraphics{[@-- $logo_file --@]} % Uncomment if you want the logo on all pages.
}
}
diff --git a/debian/changelog b/debian/changelog
index b708740..d070c46 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,9 @@
+freeside (2.1.1-1) UNRELEASED; urgency=low
+
+ * New upstream release
+
+ -- Ivan Kohler <ivan-debian@420.am> Wed, 29 Sep 2010 15:50:39 -0700
+
freeside (2.1.0-1) UNRELEASED; urgency=low
* New upstream release
diff --git a/eg/cdr_template.pm b/eg/cdr_template.pm
index 4e55c65..b423305 100644
--- a/eg/cdr_template.pm
+++ b/eg/cdr_template.pm
@@ -64,38 +64,40 @@ list of freeside CDR fields, useful ones marked with *
lastapp - Last application if appropriate
lastdata - Last application data
* startdate - Start of call (UNIX-style integer timestamp)
- answerdate - Answer time of call (UNIX-style integer timestamp)
+* answerdate - Answer time of call (UNIX-style integer timestamp)
* enddate - End time of call (UNIX-style integer timestamp)
-* duration - Total time in system, in seconds
-* billsec - Total time call is up, in seconds
-*[2] disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
+*[2] duration - Total time in system, in seconds
+*[3] billsec - Total time call is up, in seconds
+*[4] disposition - What happened to the call: ANSWERED, NO ANSWER, BUSY
amaflags - What flags to use: BILL, IGNORE etc, specified on a per
channel basis like accountcode.
-*[3] accountcode - CDR account number to use: account
+*[5] accountcode - CDR account number to use: account
uniqueid - Unique channel identifier
userfield - CDR user-defined field
cdr_type - CDR type - see FS::cdr_type (Usage = 1, S&E = 7, OC&C = 8)
-*[4] charged_party - Service number to be billed
+*[6] charged_party - Service number to be billed
upstream_currency - Wholesale currency from upstream
-*[5] upstream_price - Wholesale price from upstream
+*[7] upstream_price - Wholesale price from upstream
upstream_rateplanid - Upstream rate plan ID
rated_price - Rated (or re-rated) price
distance - km (need units field?)
islocal - Local - 1, Non Local = 0
-*[6] calltypenum - Type of call - see FS::cdr_calltype
+*[8] calltypenum - Type of call - see FS::cdr_calltype
description - Description (cdr_type 7&8 only) (used for
cust_bill_pkg.itemdesc)
quantity - Number of items (cdr_type 7&8 only)
-*[7] carrierid - Upstream Carrier ID (see FS::cdr_carrier)
+*[9] carrierid - Upstream Carrier ID (see FS::cdr_carrier)
upstream_rateid - Upstream Rate ID
svcnum - Link to customer service (see FS::cust_svc)
freesidestatus - NULL, done (or something)
[1] Auto-populated from startdate if not present
-[2] Package options available to ignore calls without a specific disposition
-[3] When using 'cdr-charged_party-accountcode' config
-[4] Auto-populated from src (normal calls) or dst (toll free calls) if not present
-[5] When using 'upstream_simple' rating method.
-[6] Set to usage class classnum when using pre-rated CDRs and usage class-based
+[2] Auto-populated to enddate - startdate on insert if not specified
+[3] Auto-populated to enddate - answerdate on insert if not specified
+[4] Package options available to ignore calls without a specific disposition
+[5] When using 'cdr-charged_party-accountcode' config
+[6] Auto-populated from src (normal calls) or dst (toll free calls) if not present
+[7] When using 'upstream_simple' rating method.
+[8] Set to usage class classnum when using pre-rated CDRs and usage class-based
taxation (local/intrastate/interstate/international)
-[7] If doing settlement charging
+[9] If doing settlement charging
diff --git a/etc/sql-reserved-words.txt b/etc/sql-reserved-words.txt
index dc507ce..89dea74 100644
--- a/etc/sql-reserved-words.txt
+++ b/etc/sql-reserved-words.txt
@@ -101,3 +101,116 @@ SQL3 words are not set in stone, but you'd do well to avoid them.
CALL, DO, ELSEIF, EXCEPTION, IF, LEAVE, LOOP, OTHERS, RESIGNAL,
RETURN, RETURNS, SIGNAL, TUPLE, WHILE
+
+from http://dev.mysql.com/doc/refman/5.6/en/reserved-words.html
+
+ACCESSIBLE ADD ALL
+ALTER ANALYZE AND
+AS ASC ASENSITIVE
+BEFORE BETWEEN BIGINT
+BINARY BLOB BOTH
+BY CALL CASCADE
+CASE CHANGE CHAR
+CHARACTER CHECK COLLATE
+COLUMN CONDITION CONSTRAINT
+CONTINUE CONVERT CREATE
+CROSS CURRENT_DATE CURRENT_TIME
+CURRENT_TIMESTAMP CURRENT_USER CURSOR
+DATABASE DATABASES DAY_HOUR
+DAY_MICROSECOND DAY_MINUTE DAY_SECOND
+DEC DECIMAL DECLARE
+DEFAULT DELAYED DELETE
+DESC DESCRIBE DETERMINISTIC
+DISTINCT DISTINCTROW DIV
+DOUBLE DROP DUAL
+EACH ELSE ELSEIF
+ENCLOSED ESCAPED EXISTS
+EXIT EXPLAIN FALSE
+FETCH FLOAT FLOAT4
+FLOAT8 FOR FORCE
+FOREIGN FROM FULLTEXT
+GENERAL GRANT GROUP
+HAVING HIGH_PRIORITY HOUR_MICROSECOND
+HOUR_MINUTE HOUR_SECOND IF
+IGNORE IGNORE_SERVER_IDS IN
+INDEX INFILE INNER
+INOUT INSENSITIVE INSERT
+INT INT1 INT2
+INT3 INT4 INT8
+INTEGER INTERVAL INTO
+IS ITERATE JOIN
+KEY KEYS KILL
+LEADING LEAVE LEFT
+LIKE LIMIT LINEAR
+LINES LOAD LOCALTIME
+LOCALTIMESTAMP LOCK LONG
+LONGBLOB LONGTEXT LOOP
+LOW_PRIORITY MASTER_HEARTBEAT_PERIOD MASTER_SSL_VERIFY_SERVER_CERT
+MATCH MAXVALUE MEDIUMBLOB
+MEDIUMINT MEDIUMTEXT MIDDLEINT
+MINUTE_MICROSECOND MINUTE_SECOND MOD
+MODIFIES NATURAL NOT
+NO_WRITE_TO_BINLOG NULL NUMERIC
+ON OPTIMIZE OPTION
+OPTIONALLY OR ORDER
+OUT OUTER OUTFILE
+PRECISION PRIMARY PROCEDURE
+PURGE RANGE READ
+READS READ_WRITE REAL
+REFERENCES REGEXP RELEASE
+RENAME REPEAT REPLACE
+REQUIRE RESIGNAL RESTRICT
+RETURN REVOKE RIGHT
+RLIKE SCHEMA SCHEMAS
+SECOND_MICROSECOND SELECT SENSITIVE
+SEPARATOR SET SHOW
+SIGNAL SLOW SMALLINT
+SPATIAL SPECIFIC SQL
+SQLEXCEPTION SQLSTATE SQLWARNING
+SQL_BIG_RESULT SQL_CALC_FOUND_ROWS SQL_SMALL_RESULT
+SSL STARTING STRAIGHT_JOIN
+TABLE TERMINATED THEN
+TINYBLOB TINYINT TINYTEXT
+TO TRAILING TRIGGER
+TRUE UNDO UNION
+UNIQUE UNLOCK UNSIGNED
+UPDATE USAGE USE
+USING UTC_DATE UTC_TIME
+UTC_TIMESTAMP VALUES VARBINARY
+VARCHAR VARCHARACTER VARYING
+WHEN WHERE WHILE
+WITH WRITE XOR
+YEAR_MONTH ZEROFILL
+
+The following are new reserved words in MySQL 5.5:
+GENERAL IGNORE_SERVER_IDS MASTER_HEARTBEAT_PERIOD
+MAXVALUE RESIGNAL SIGNAL
+SLOW
+
+MySQL permits some keywords to be used as unquoted identifiers because many people previously used them. Examples are those in the following list:
+
+ *
+
+ ACTION
+ *
+
+ BIT
+ *
+
+ DATE
+ *
+
+ ENUM
+ *
+
+ NO
+ *
+
+ TEXT
+ *
+
+ TIME
+ *
+
+ TIMESTAMP
+
diff --git a/fs_selfservice/FS-SelfService/SelfService.pm b/fs_selfservice/FS-SelfService/SelfService.pm
index 7e6821b..a0e632c 100644
--- a/fs_selfservice/FS-SelfService/SelfService.pm
+++ b/fs_selfservice/FS-SelfService/SelfService.pm
@@ -1393,6 +1393,18 @@ END
my $county_html = $script_html;
if ( $countyflag ) {
$county_html .= qq!<SELECT NAME="${prefix}county" onChange="$param->{'onchange'}">!;
+ foreach my $county (
+ sort keys %{ $cust_main_county{$param->{'selected_country'}}{$param->{'selected_state'}} }
+ ) {
+ my $text = $county || '(n/a)';
+ $county_html .= qq!<OPTION VALUE="$county"!.
+ ($county eq $param->{'selected_county'} ?
+ ' SELECTED>' :
+ '>'
+ ).
+ $text.
+ '</OPTION>';
+ }
$county_html .= '</SELECT>';
} else {
$county_html .=
diff --git a/fs_selfservice/FS-SelfService/cgi/discount_term.html b/fs_selfservice/FS-SelfService/cgi/discount_term.html
new file mode 100644
index 0000000..7d9ee4d
--- /dev/null
+++ b/fs_selfservice/FS-SelfService/cgi/discount_term.html
@@ -0,0 +1,17 @@
+<%=
+if ( scalar(keys %discount_terms_hash) ) {
+ $OUT .= '<TR>';
+ $OUT .= '<TD ALIGN="right">Prepayment for</TD>';
+ $OUT .= '<TD>';
+ $OUT .= '<SELECT NAME="discount_term">';
+ $OUT .= qq(<OPTION VALUE="">1 month\n);
+ foreach ( keys %discount_terms_hash ) {
+ $selected = $discount_term eq $_ ? ' SELECTED' : '';
+ $OUT .= qq(<OPTION$selected VALUE="$_">$_ months\n);
+ }
+ $OUT .= '</SELECT>';
+ $OUT .= '</TD>';
+ $OUT .= '</TR>';
+}
+$OUT .= '';
+%>
diff --git a/fs_selfservice/FS-SelfService/cgi/make_ach_payment.html b/fs_selfservice/FS-SelfService/cgi/make_ach_payment.html
index 09391e7..5b81b00 100644
--- a/fs_selfservice/FS-SelfService/cgi/make_ach_payment.html
+++ b/fs_selfservice/FS-SelfService/cgi/make_ach_payment.html
@@ -21,6 +21,7 @@
</TD></TR></TABLE>
</TD>
</TR>
+<%= include('discount_term') %>
<%= include('check') %>
<TR>
<TD COLSPAN=2>
diff --git a/fs_selfservice/FS-SelfService/cgi/make_payment.html b/fs_selfservice/FS-SelfService/cgi/make_payment.html
index e454647..645b68e 100644
--- a/fs_selfservice/FS-SelfService/cgi/make_payment.html
+++ b/fs_selfservice/FS-SelfService/cgi/make_payment.html
@@ -20,7 +20,9 @@
$<INPUT TYPE="text" NAME="amount" SIZE=8 VALUE="<%=sprintf("%.2f",$balance)%>">
</TD></TR></TABLE>
</TD>
-</TR><TR>
+</TR>
+<%= include('discount_term') %>
+<TR>
<TH ALIGN="right">Card&nbsp;type</TH>
<TD COLSPAN=7>
<SELECT NAME="card_type"><OPTION></OPTION>
diff --git a/fs_selfservice/FS-SelfService/cgi/myaccount.html b/fs_selfservice/FS-SelfService/cgi/myaccount.html
index 0de7385..6b4187f 100644
--- a/fs_selfservice/FS-SelfService/cgi/myaccount.html
+++ b/fs_selfservice/FS-SelfService/cgi/myaccount.html
@@ -14,7 +14,15 @@ Hello <%= $name %>!<BR><BR>
if (scalar(grep $_, @hide_payment_fields)) {
$OUT .= qq! <B><A HREF="${url}make_thirdparty_payment&payby_method=CC">Make a payment</A></B><BR><BR>!;
} else {
- $OUT .= qq! <B><A HREF="${url}make_payment">Make a payment</A></B><BR><BR>!;
+ $OUT .= qq! <B><A HREF="${url}make_payment">Make a payment</A></B><BR>!;
+ foreach my $term ( sort { $b <=> $a } keys %discount_terms_hash ) {
+ my $saved = $discount_terms_hash{$term}->[1];
+ my $amount = $discount_terms_hash{$term}->[2];
+ my $savings = ( $amount + $saved > 0 )
+ ? sprintf('%d', $saved / ( $amount + $saved ) * 100 ) : '0';
+ $OUT .= qq! <B><A HREF="${url}make_term_payment;discount_term=$term;amount=$amount">Save $savings\% by paying for $term months: $amount</A></B><BR>!;
+ }
+ $OUT .= qq! <BR>!;
}
} %>
<%=
diff --git a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi
index 2252852..711bd4e 100644
--- a/fs_selfservice/FS-SelfService/cgi/selfservice.cgi
+++ b/fs_selfservice/FS-SelfService/cgi/selfservice.cgi
@@ -73,7 +73,7 @@ $session_id = $cgi->param('session');
#order|pw_list XXX ???
$cgi->param('action') =~
- /^(myaccount|view_invoice|make_payment|make_ach_payment|make_thirdparty_payment|payment_results|ach_payment_results|recharge_prepay|recharge_results|logout|change_bill|change_ship|change_pay|process_change_bill|process_change_ship|process_change_pay|customer_order_pkg|process_order_pkg|customer_change_pkg|process_change_pkg|process_order_recharge|provision|provision_svc|process_svc_acct|process_svc_external|delete_svc|view_usage|view_usage_details|view_cdr_details|view_support_details|change_password|process_change_password)$/
+ /^(myaccount|view_invoice|make_payment|make_ach_payment|make_term_payment|make_thirdparty_payment|payment_results|ach_payment_results|recharge_prepay|recharge_results|logout|change_bill|change_ship|change_pay|process_change_bill|process_change_ship|process_change_pay|customer_order_pkg|process_order_pkg|customer_change_pkg|process_change_pkg|process_order_recharge|provision|provision_svc|process_svc_acct|process_svc_external|delete_svc|view_usage|view_usage_details|view_cdr_details|view_support_details|change_password|process_change_password)$/
or die "unknown action ". $cgi->param('action');
my $action = $1;
@@ -105,7 +105,8 @@ do_template($action, {
#--
-sub myaccount { customer_info( 'session_id' => $session_id ); }
+use Data::Dumper;
+sub myaccount { my $result = customer_info( 'session_id' => $session_id ); warn Dumper($result); $result;}
sub change_bill { my $payment_info =
payment_info( 'session_id' => $session_id );
@@ -427,6 +428,10 @@ sub payment_results {
$cgi->param('paybatch') =~ /^([\w\-\.]+)$/ or die "illegal paybatch";
my $paybatch = $1;
+ $cgi->param('discount_term') =~ /^(\d*)$/ or die "illegal discount_term";
+ my $discount_term = $1;
+
+
process_payment(
'session_id' => $session_id,
'payby' => 'CARD',
@@ -445,6 +450,7 @@ sub payment_results {
'save' => $save,
'auto' => $auto,
'paybatch' => $paybatch,
+ 'discount_term' => $discount_term,
);
}
@@ -529,6 +535,20 @@ sub make_thirdparty_payment {
realtime_collect( 'session_id' => $session_id, 'method' => $1 );
}
+sub make_term_payment {
+ $cgi->param('amount') =~ /^(\d+\.\d{2})$/
+ or die "illegal payment amount";
+ my $balance = $1;
+ $cgi->param('discount_term') =~ /^(\d+)$/
+ or die "illegal discount term";
+ my $discount_term = $1;
+ $action = 'make_payment';
+ ({ %{payment_info( 'session_id' => $session_id )},
+ 'balance' => $balance,
+ 'discount_term' => $discount_term,
+ })
+}
+
sub recharge_prepay {
customer_info( 'session_id' => $session_id );
}
diff --git a/fs_selfservice/FS-SelfService/cgi/signup.html b/fs_selfservice/FS-SelfService/cgi/signup.html
index 375137b..7a56a31 100755
--- a/fs_selfservice/FS-SelfService/cgi/signup.html
+++ b/fs_selfservice/FS-SelfService/cgi/signup.html
@@ -74,8 +74,6 @@ Contact Information
<TR>
<TH ALIGN="right"><font color="#ff0000">*</font>City</TH>
<TD><INPUT TYPE="text" NAME="city" VALUE="<%= $city %>"></TD>
- <TH ALIGN="right"><font color="#ff0000">*</font>State/Country</TH>
- <TD>
<%=
($county_html, $state_html, $country_html) =
regionselector( {
@@ -87,8 +85,11 @@ Contact Information
locales => \@cust_main_county,
} );
- "$county_html $state_html";
%>
+ <TH ALIGN="right"><font color="#ff0000">*</font>
+ <%= ($county_html =~ /SELECT/) ? 'County/State' : 'State' %></TH>
+ <TD>
+ <%= "$county_html $state_html"; %>
</TD>
<TH><font color="#ff0000">*</font>Zip</TH>
<TD><INPUT TYPE="text" NAME="zip" SIZE=10 VALUE="<%= $zip %>"></TD>
diff --git a/fs_selfservice/drupal/admin.inc b/fs_selfservice/drupal/admin.inc
new file mode 100644
index 0000000..1fb7925
--- /dev/null
+++ b/fs_selfservice/drupal/admin.inc
@@ -0,0 +1,56 @@
+<?php
+
+function freeside_admin() {
+ return drupal_get_form('freeside_admin_form');
+}
+
+function freeside_admin_form() {
+ $hostname = variable_get('freeside_hostname','');
+
+ $form = array(
+ 'freeside_hostname'=> array(
+ '#type' => 'textfield',
+ '#title' => t('Freeside server address'),
+ '#default_value'=>variable_get('freeside_hostname',''),
+ '#required'=>1,
+ ),
+ );
+
+ if($hostname) {
+ $freeside = new FreesideSelfService();
+ $signup_info = $freeside->signup_info(
+ array(
+ 'keys' => array('agent')
+ )
+ ); // no agent in this request
+
+ $agents = array();
+ foreach((array)$signup_info['agent'] as $a) {
+ $agents[$a['agentnum']] = $a['agent'];
+ }
+
+ $form['freeside_agentnum'] = array(
+ '#type' => 'select',
+ '#title' => t('Signup agent'),
+ '#default_value'=>variable_get('freeside_agentnum',''),
+ '#required'=>1,
+ '#options'=> $agents,
+ );
+
+ $form['freeside_debug'] = array(
+ '#type' => 'checkbox',
+ '#title' => t('Enable debugging'),
+ '#default_value'=>variable_get('freeside_debug',0),
+ );
+
+ $form['freeside_redirect_after_signup'] = array(
+ '#type' => 'textfield',
+ '#title' => t('Success page (URL or Drupal path)'),
+ '#default_value'=>variable_get('freeside_redirect_after_signup','/'),
+ );
+ }
+
+ return system_settings_form($form);
+}
+
+?>
diff --git a/fs_selfservice/drupal/freeside.class.php b/fs_selfservice/drupal/freeside.class.php
new file mode 100644
index 0000000..161156a
--- /dev/null
+++ b/fs_selfservice/drupal/freeside.class.php
@@ -0,0 +1,33 @@
+<?php
+
+class FreesideSelfService {
+
+ public $URL = '';
+ function FreesideSelfService() {
+ $this->URL = 'http://' . variable_get('freeside_hostname','') . ':8080';
+ $this;
+ }
+
+ public function __call($name, $arguments) {
+
+ error_log("[FreesideSelfService] $name called, sending to ". $this->URL);
+
+ $request = xmlrpc_encode_request("FS.ClientAPI_XMLRPC.$name", $arguments);
+ $context = stream_context_create( array( 'http' => array(
+ 'method' => "POST",
+ 'header' => "Content-Type: text/xml",
+ 'content' => $request
+ )));
+ $file = file_get_contents($this->URL, false, $context);
+ $response = xmlrpc_decode($file);
+ if (xmlrpc_is_fault($response)) {
+ trigger_error("[FreesideSelfService] XML-RPC communication error: $response[faultString] ($response[faultCode])");
+ } else {
+ //error_log("[FreesideSelfService] $response");
+ return $response;
+ }
+ }
+
+}
+
+?>
diff --git a/fs_selfservice/drupal/freeside.info b/fs_selfservice/drupal/freeside.info
new file mode 100644
index 0000000..957c7b9
--- /dev/null
+++ b/fs_selfservice/drupal/freeside.info
@@ -0,0 +1,3 @@
+name = Freeside
+description = Freeside self-service
+core = 6.x
diff --git a/fs_selfservice/drupal/freeside.module b/fs_selfservice/drupal/freeside.module
new file mode 100644
index 0000000..a806e3b
--- /dev/null
+++ b/fs_selfservice/drupal/freeside.module
@@ -0,0 +1,32 @@
+<?php
+// init freeside API
+require('freeside.class.php');
+
+// menu actions and node paths
+function freeside_menu() {
+ $items = array();
+ $items['freeside/signup'] = array(
+ 'title' => t('New customer'),
+ 'page callback' => 'freeside_signup',
+ 'access arguments' => array('access content'),
+ 'description' => t('New Customer Signup'),
+ 'file' => 'signup.inc',
+ );
+ $items['admin/settings/freeside'] = array(
+ 'title' => t('Configure Freeside'),
+ 'page callback' => 'freeside_admin',
+ 'access arguments' => array('administer freeside'),
+ 'description' => t('Configure Freeside self-service'),
+ 'file' => 'admin.inc',
+ );
+ return $items;
+}
+
+// access control
+function freeside_perm() {
+ return array(
+ 'administer freeside'
+ );
+}
+
+?>
diff --git a/fs_selfservice/drupal/signup.inc b/fs_selfservice/drupal/signup.inc
new file mode 100644
index 0000000..b3e54f0
--- /dev/null
+++ b/fs_selfservice/drupal/signup.inc
@@ -0,0 +1,354 @@
+<?php
+function freeside_signup() {
+ return drupal_get_form('freeside_signup_form');
+}
+
+function dkpr($var) {
+ /* "debug kpr": Krumo-print $var if debugging is on */
+ static $debug;
+ if(empty($debug)) $debug = variable_get('freeside_debug','');
+ if($debug) {
+ kpr($var);
+ }
+}
+
+function signup_info($keys) {
+ /* local cache, because transporting the entire signup_info
+ through XML-RPC is incredibly slow. If you change the config,
+ you can flush the local cache with the "Clear cached data"
+ button on the Drupal "Performance" menu. */
+ $cid = 'FS_signup_info';
+ $info = cache_get($cid);
+ if($info) {
+ return($info->data);
+ }
+ else {
+ $packet = array(
+ 'agentnum' => variable_get('freeside_agentnum',''),
+ 'promo_code' => '',
+ 'reg_code' => '',
+ 'keys' => $keys,
+ );
+
+ $freeside = new FreesideSelfService();
+ $freeside->clear_signup_cache();
+ $info = $freeside->signup_info($packet);
+ cache_set($cid, $info, 'cache', CACHE_TEMPORARY);
+ return($info);
+ }
+}
+
+function subextract($array, $key) {
+ // map { $_->{$key} } (...)
+ $out = array();
+ foreach ($array as $i) {
+ $out[] = $i[$key];
+ }
+ return $out;
+}
+
+function freeside_signup_form($form_state) {
+ dkpr($form_state);
+
+ $agentnum = variable_get('freeside_agentnum','');
+ if( !$agentnum || !(variable_get('freeside_hostname','')) ) {
+ drupal_set_message(t('Freeside self-service is not yet configured.'),'error');
+ return array();
+ }
+
+ $freeside = new FreesideSelfService();
+ $keys = array(
+ // all the signup_info that we need
+ 'part_referral',
+ 'refnum',
+ 'emailinvoiceonly',
+ 'payby',
+ 'payby_longname',
+ 'part_pkg',
+ 'default_pkgpart',
+ 'signup_service',
+ );
+ $signup_info = signup_info($keys);
+ dkpr($signup_info);
+
+ $form = array();
+
+ $refs = $signup_info['part_referral'];
+ $form['refnum'] = count($refs) > 1 ?
+ array(
+ '#type' => 'select',
+ '#title' => t('How did you hear about us?'),
+ '#options'=> array_combine(
+ subextract($refs, 'refnum'),
+ subextract($refs, 'referral')
+ ),
+ '#default_value'=>$signup_info['refnum'],
+ ) : array (
+ '#type' => 'hidden',
+ '#value' => $refs[0]['refnum'],
+ );
+
+ $form['contact'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('Contact Information'),
+ 'last' => array(
+ '#prefix' => '<div class="container-inline">',
+ '#type' => 'textfield',
+ '#title' => t('Contact name (last, first)'),
+ '#size' => 20,
+ '#required' => 1,
+ ),
+ 'first' => array(
+ '#type' => 'textfield',
+ '#size' => 20,
+ '#required' => 1,
+ '#suffix' => '</div>',
+ ),
+ 'company' => array(
+ '#type' => 'textfield',
+ '#title' => t('Company'),
+ '#size' => 20,
+ ),
+ 'address1'=> array(
+ '#type' => 'textfield',
+ '#title' => t('Address'),
+ '#size' => 30,
+ '#required'=>1,
+ ),
+ 'address2'=> array(
+ '#type' => 'textfield',
+ '#size' => 30,
+ ),
+ 'city' => array(
+ '#prefix' => '<div class="container-inline">',
+ '#type' => 'textfield',
+ '#title' => t('City'),
+ '#size' => 15,
+ '#required'=>1,
+ ),
+ 'state' => array(
+ '#type' => 'textfield',
+ '#title' => t('State'),
+ '#size' => 2,
+ '#required'=>1,
+ '#default_value'=>$info['statedefault'],
+ ),
+ 'zip' => array(
+ '#type' => 'textfield',
+ '#title' => t('Zip'),
+ '#size' => 10,
+ '#required'=>1,
+ '#suffix' => '</div>',
+ ),
+ 'daytime' => array(
+ '#type' => 'textfield',
+ '#title' => t('Daytime Phone'),
+ '#size' => 18,
+ ),
+ 'night' => array(
+ '#type' => 'textfield',
+ '#title' => t('Night Phone'),
+ '#size' => 18,
+ ),
+ );
+
+ $emailinvoiceonly = $signup_info['emailinvoiceonly'];
+
+ $form['billing'] = array(
+ 'invoicing_list' => array(
+ '#type' => 'textfield',
+ '#title' => t('Email invoice to'),
+ '#size' => '40',
+ '#required'=>$emailinvoiceonly,
+ ),
+ '#type' => 'fieldset',
+ '#title' => t('Billing Information'),
+ 'invoicing_list_POST' => array(
+ '#type' => $emailinvoiceonly ? 'hidden' : 'checkbox',
+ '#title' => t('Send a paper invoice'),
+ '#default_value' => 0,
+ ),
+ );
+
+ if( count($signup_info['payby']) > 1 ) {
+ $form['billing']['payby'] = array(
+ '#type' => 'select',
+ '#title' => t('Payment method'),
+ '#options'=> array_combine(
+ $signup_info['payby'],
+ $signup_info['payby_longname']
+ ),
+ );
+ }
+ else {
+ $form['billing']['payby'] = array(
+ '#type' => 'hidden',
+ '#value' => $signup_info['payby'][0],
+ );
+ }
+ $form['billing']['payby_CARD'] = array(
+ '#type' => 'fieldset',
+ 'cardnum' => array(
+ '#prefix' => '<div class="container-inline">',
+ '#type' => 'textfield',
+ '#title' => t('Credit card number'),
+ '#size' => 20,
+ '#maxlength'=>20,
+ '#required'=>1,
+ '#suffix' => '</div>',
+ ),
+ 'expmonth' => array(
+ '#prefix' => '<div class="container-inline">',
+ '#type' => 'textfield',
+ '#title' => t('Expiration date'),
+ '#size' => 2,
+ '#required'=>1,
+ '#maxlength' => 2,
+ ),
+ 'expyear' => array(
+ '#field_prefix' => '/',
+ '#type' => 'textfield',
+ '#size' => 2,
+ '#maxlength' => 2,
+ '#required'=>1,
+ '#suffix' => '</div>',
+ ),
+ 'paycvv' => array(
+ '#prefix' => '<div class="container-inline">',
+ '#type' => 'textfield',
+ '#title' => 'CVV',
+ '#size' => 3,
+ '#maxlength' => 3,
+ '#required'=>1,
+ '#suffix' => '</div>',
+ ),
+ 'cardname'=> array(
+ '#prefix' => '<div class="container-inline">',
+ '#type' => 'textfield',
+ '#title' => t('Exact name on card'),
+ '#size' => 40,
+ '#maxlength'=>60,
+ '#suffix' => '</div>',
+ ),
+ );
+
+ $pkgs = $signup_info['part_pkg'];
+ $form['package'] = array(
+ '#type' => 'fieldset',
+ '#title' => t('First Package'),
+ 'pkgpart' => (count($pkgs) > 1 ?
+ array(
+ '#type' => 'select',
+ '#title' => '',
+ '#options'=> array_combine(
+ subextract($pkgs, 'pkgpart'),
+ subextract($pkgs, 'pkg')
+ ),
+ '#default_value'=>$signup_info['default_pkgpart'],
+ ) : array (
+ '#type' => 'hidden',
+ '#value' => $pkgs[0]['pkgpart'],
+ )
+ ),
+ );
+
+ switch($signup_info['signup_service']) {
+ case 'svc_acct':
+ $form['package'] += array(
+ 'username'=> array(
+ '#type' => 'textfield',
+ '#title' => t('Username'),
+ '#size' => 20,
+ '#required'=>1,
+ ),
+ 'password'=> array(
+ '#type' => 'password_confirm',
+ '#size' => 20,
+ '#required'=>1,
+ '#process'=> array('freeside_expand_password_confirm'),
+ )
+ );
+ break;
+ case 'svc_pbx':
+ break; // nothing yet implemented
+ }
+ $form['package']['pkgpart']['#default_value'] = $signup_info['default_pkgpart'];
+
+ $form['submit'] = array(
+ '#type' => 'submit',
+ '#value' => 'Sign me up!',
+ );
+ return $form;
+}
+
+/* workaround for silly Drupal behavior */
+function freeside_expand_password_confirm($element) {
+ $element = expand_password_confirm($element);
+ $element['pass1']['#attributes']['value'] = $element['#value']['pass1'];
+ $element['pass2']['#attributes']['value'] = $element['#value']['pass2'];
+ return $element;
+}
+
+function freeside_signup_form_submit($form, &$form_state) {
+ $freeside = new FreesideSelfService();
+ $values = $form_state['values'];
+ dkpr($values);
+
+ $customer = array();
+ $customer['agentnum'] = variable_get('freeside_agentnum','');
+ foreach( array( 'first',
+ 'last',
+ 'address1',
+ 'address2',
+ 'city',
+ 'state',
+ 'zip',
+ 'daytime',
+ 'night',
+ 'fax',
+ 'payby',
+ 'refnum',
+ 'invoicing_list',
+ 'pkgpart',
+ 'username'
+ )
+ as $field ) {
+ $customer[$field] = $values[$field];
+ }
+ if($values['invoicing_list_POST']) {
+ $customer['invoicing_list'] =
+ implode(',', array($customer['invoicing_list'], 'POST'));
+ }
+ $customer['_password'] = $values['password'];
+ $customer['country'] = 'US';
+ if($customer['payby'] == 'CARD') {
+ $customer['payinfo'] = preg_replace('/\D/','',$values['cardnum']);
+ $customer['paydate'] = $values['expmonth'] . '/' . $values['expyear'];
+ $customer['payname'] = isset($values['cardname']) ?
+ $values['cardname'] :
+ ($values['first'] . ' ' . $values['last']);
+ $customer['paycvv'] = $values['paycvv'];
+ }
+ /* other paybys not implemented */
+
+ dkpr($customer);
+ $response = $freeside->new_customer($customer);
+ dkpr($response);
+ error_log("[new_customer] received response from Freeside: $response");
+ $error = $response['error'];
+
+ if ( $error ) {
+ drupal_set_message(t("Signup error: $error"), 'error');
+ $form_state['redirect'] = FALSE;
+ }
+ else {
+ drupal_set_message(t("Signup successful!"),'status');
+ $form_state['redirect'] = array(
+ variable_get('freeside_redirect_after_signup','/'),
+ //query string would go here
+ //'custnum='.$response['custnum'].'&svcnum='.$response['svcnum'],
+ );
+ }
+}
+
+?>
diff --git a/fs_selfservice/php/freeside_order_pkg_example.php b/fs_selfservice/php/freeside_order_pkg_example.php
new file mode 100644
index 0000000..395ad11
--- /dev/null
+++ b/fs_selfservice/php/freeside_order_pkg_example.php
@@ -0,0 +1,38 @@
+<?php
+
+require('freeside.class.php');
+$freeside = new FreesideSelfService();
+
+$response = $freeside->order_pkg( array(
+ 'session_id' => $_POST['session_id'],
+ 'pkgpart' => 15, #Freesize 25
+ #if needed# 'svcpart' =>
+ 'id' => $_POST['id'], #unique integer ID
+ 'name' => $_POST['name'], #text name
+) );
+
+$error = $response['error'];
+
+if ( ! $error ) {
+
+ // sucessful order
+
+ $pkgnum = $response['pkgnum'];
+ $svcnum = $response['svcnum'];
+
+ error_log("[order_pkg] package ordered pkgnum=$pkgnum, svcnum=$svcnum");
+
+ // store svcnum, to be used for the customer_status call
+
+} else {
+
+ // unsucessful order
+
+ error_log("[order_pkg] error ordering package: $error");
+
+ // display error message to user
+
+}
+
+
+?>
diff --git a/htetc/freeside-rt.conf b/htetc/freeside-rt.conf
index 2753bc6..c89d2d5 100644
--- a/htetc/freeside-rt.conf
+++ b/htetc/freeside-rt.conf
@@ -1,10 +1,10 @@
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt>
+RedirectMatch permanent (.*)/$ $1/index.html
+</Directory>
+
<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/NoAuth>
-<Limit GET POST>
-allow from all
-Satisfy any
SetHandler perl-script
PerlHandler HTML::Mason
-</Limit>
</Directory>
<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/REST/1.0/NoAuth>
@@ -20,12 +20,22 @@ PerlHandler HTML::Mason
SetHandler None
</DirectoryMatch>
-<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Ticket/Attachment>
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Admin>
SetHandler perl-script
PerlHandler HTML::Mason
</Directory>
-<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Ticket/Graphs>
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Approvals>
+SetHandler perl-script
+PerlHandler HTML::Mason
+</Directory>
+
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Dashboards>
+SetHandler perl-script
+PerlHandler HTML::Mason
+</Directory>
+
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Download>
SetHandler perl-script
PerlHandler HTML::Mason
</Directory>
@@ -35,7 +45,23 @@ SetHandler perl-script
PerlHandler HTML::Mason
</Directory>
-<DirectoryMatch "^%%%FREESIDE_DOCUMENT_ROOT%%%/rt/RTx/Statistics/.*/Elements>
-SetHandler perl-script
-PerlHandler HTML::Mason
-</DirectoryMatch>
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Ticket/Attachment>
+SetHandler perl-script
+PerlHandler HTML::Mason
+</Directory>
+
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Ticket/AttachmentWithHeaders>
+SetHandler perl-script
+PerlHandler HTML::Mason
+</Directory>
+
+<Directory %%%FREESIDE_DOCUMENT_ROOT%%%/rt/Ticket/Graphs>
+SetHandler perl-script
+PerlHandler HTML::Mason
+</Directory>
+
+#this one's not used anymore
+#<DirectoryMatch "^%%%FREESIDE_DOCUMENT_ROOT%%%/rt/RTx/Statistics/.*/Elements>
+#SetHandler perl-script
+#PerlHandler HTML::Mason
+#</DirectoryMatch>
diff --git a/htetc/handler.pl b/htetc/handler.pl
index 94377cf..eb9e67e 100644
--- a/htetc/handler.pl
+++ b/htetc/handler.pl
@@ -57,16 +57,17 @@ sub handler
if ( $r->filename =~ /\/rt\// ) { #RT
- $ah->interp($rt_interp);
+ # 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;
+
local $SIG{__WARN__};
local $SIG{__DIE__};
RT::Init();
- # 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;
+ $ah->interp($rt_interp);
} else {
diff --git a/httemplate/browse/acct_snarf.html b/httemplate/browse/acct_snarf.html
new file mode 100644
index 0000000..f610994
--- /dev/null
+++ b/httemplate/browse/acct_snarf.html
@@ -0,0 +1,78 @@
+<% include('elements/browse.html',
+ 'title' => "Remote POP accounts for $svc_label: $svc_value",
+ 'name_singular' => 'Remote POP account',
+ 'html_init' => $html_init,
+ 'query' => { 'table' => 'acct_snarf',
+ 'hashref' => { 'svcnum' => $svcnum },
+ #'order_by' => 'ORDER BY priority DESC',
+ },
+ 'count_query' => $count_query,
+ 'header' => [ 'Name',
+ 'Mail server',
+ 'Username',
+ #'Password',
+ 'Poll every',
+ #'Options',
+ 'Leave',
+ 'APOP',
+ 'TLS',
+ 'Mailbox',
+ '', #delete
+ ],
+ 'fields' => [ 'snarfname',
+ 'machine',
+ 'username',
+ sub { FS::acct_snarf->check_freq_labels->{shift->check_freq} },
+ 'leave',
+ 'apop',
+ 'tls',
+ 'mailbox',
+ ],
+ #'align'
+ 'links' => [ $edit_sub, $edit_sub, $edit_sub, '',
+ '', '', '', '', $del_sub ],
+ )
+%>
+<%init>
+
+$cgi->param('svcnum') =~ /^(\d+)$/ or die 'no svcnum';
+my $svcnum = $1;
+
+#agent virt so you can't do cross-agent snarfing
+my $cust_svc = qsearchs('cust_svc', { 'svcnum' => $svcnum })
+ or die 'unknown svcnum';
+my $part_svc = $cust_svc->part_svc;
+
+my $count_query = "SELECT COUNT(*) FROM acct_snarf WHERE svcnum = $svcnum";
+
+my($svc_label, $svc_value, $svcdb) = $cust_svc->label;
+
+my $view = FS::UI::Web::svc_url( 'm' => $m,
+ 'action' => 'view',
+ 'part_svc' => $part_svc,
+ 'svc' => $cust_svc,
+ );
+
+my $html_init =
+ qq(<A HREF="$view">View this $svc_label</A><BR><BR>).
+ qq!<A HREF="${p}edit/acct_snarf.html?svcnum=$svcnum">Add new remote POP account</A><BR>!.
+ '<BR>'.
+ qq!
+ <SCRIPT>
+ function areyousure_delete(href) {
+ areyousure(href,"Are you sure you want to delete this remote POP account?");
+ }
+ function areyousure(href,message) {
+ if (confirm(message) == true)
+ window.location.href = href;
+ }
+ </SCRIPT>
+!;
+
+my $edit_sub = [ $p.'edit/acct_snarf.html?', 'snarfnum' ];
+my $del_sub = sub {
+ my $snarfnum = shift->snarfnum;
+ [ "javascript:areyousure_delete('${p}misc/delete-acct_snarf.html?$snarfnum')", '' ];
+};
+
+</%init>
diff --git a/httemplate/browse/cgp_rule.html b/httemplate/browse/cgp_rule.html
index 8a427b8..8ea7571 100644
--- a/httemplate/browse/cgp_rule.html
+++ b/httemplate/browse/cgp_rule.html
@@ -44,11 +44,12 @@ my $html_init =
if ( $part_svc->svcdb eq 'svc_domain' ) {
- #areyousure for adding these?
+ #XXX add areyousure javscript confirmation for adding these
foreach my $line ( FS::Conf->new->config('cgp_rule-domain_templates') ) {
$line =~ /^\s*(\d+)\s+(.+)\s*$/ or next;
my($t_svcnum, $t_name) = ( $1, $2 );
+ next if $t_svcnum == $svcnum;
$html_init .=
qq!<A HREF="${p}misc/clone-cgp_rule.html?clone=$t_svcnum;svcnum=$svcnum">!
."Add $t_name rule</A><BR>";
diff --git a/httemplate/browse/msg_template.html b/httemplate/browse/msg_template.html
index 0cd33c7..252ee1f 100644
--- a/httemplate/browse/msg_template.html
+++ b/httemplate/browse/msg_template.html
@@ -9,7 +9,7 @@
'disableable' => 1,
'disabled_statuspos' => 2,
'agent_virt' => 1,
- 'agent_null_right' => 'Edit global templates',
+ 'agent_null_right' => ['Edit global templates','Configuration'],
'agent_pos' => 3,
'header' => [ 'Name' ],
'fields' => [ 'msgname' ],
diff --git a/httemplate/browse/part_event.html b/httemplate/browse/part_event.html
index 3d7c245..f68f06b 100644
--- a/httemplate/browse/part_event.html
+++ b/httemplate/browse/part_event.html
@@ -150,7 +150,7 @@ my $html_init =
foreach my $part_event ( qsearch('part_event', {'diabled'=>''}) ) {
$html_init .= '<OPTION VALUE="'. $part_event->eventpart. '">'.
- $part_event->event. '</OPTION>';
+ $part_event->eventpart. ': '. $part_event->event. '</OPTION>';
}
$html_init .= '</SELECT><INPUT TYPE="submit" VALUE="Clone existing event">'.
diff --git a/httemplate/browse/part_pkg.cgi b/httemplate/browse/part_pkg.cgi
index 42eb5df..26e0170 100755
--- a/httemplate/browse/part_pkg.cgi
+++ b/httemplate/browse/part_pkg.cgi
@@ -32,6 +32,7 @@ my $acl_edit = $curuser->access_right($edit);
my $acl_edit_global = $curuser->access_right($edit_global);
my $acl_config = $curuser->access_right('Configuration'); #to edit services
#and agent types
+ #and bulk change
die "access denied"
unless $acl_edit || $acl_edit_global;
@@ -96,6 +97,13 @@ $select = "
*,
( $count_cust_pkg
+ AND ( setup IS NULL OR setup = 0 )
+ AND ( cancel IS NULL OR cancel = 0 )
+ AND ( susp IS NULL OR susp = 0 )
+ ) AS num_not_yet_billed,
+
+ ( $count_cust_pkg
+ AND setup IS NOT NULL AND setup != 0
AND ( cancel IS NULL OR cancel = 0 )
AND ( susp IS NULL OR susp = 0 )
) AS num_active,
@@ -195,6 +203,9 @@ push @fields, sub {
my $part_pkg = shift;
(my $plan = $plan_labels{$part_pkg->plan} ) =~ s/ /&nbsp;/g;
my $is_recur = ( $part_pkg->freq ne '0' );
+ my @discounts = sort { $a->months <=> $b->months }
+ map { $_->discount }
+ $part_pkg->part_pkg_discount;
[
[
@@ -238,6 +249,28 @@ push @fields, sub {
}
$part_pkg->bill_part_pkg_link
),
+ ( scalar(@discounts)
+ ? [
+ { data => '<b>Discounts</b>',
+ align=>'center', #?
+ colspan=>2,
+ }
+ ]
+ : ()
+ ),
+ ( scalar(@discounts)
+ ? map {
+ [
+ { data => $_->months. ':',
+ align => 'right',
+ },
+ { data => $_->amount ? '$'. $_->amount : $_->percent. '%'
+ }
+ ]
+ }
+ @discounts
+ : ()
+ ),
];
# $plan_labels{$part_pkg->plan}.'<BR>'.
@@ -284,6 +317,7 @@ if ( $acl_edit_global ) {
#if ( $cgi->param('active') ) {
push @header, 'Customer<BR>packages';
my %col = (
+ 'not yet billed' => '009999', #teal? cyan?
'active' => '00CC00',
'suspended' => 'FF9900',
'cancelled' => 'FF0000',
@@ -292,8 +326,8 @@ if ( $acl_edit_global ) {
);
my $cust_pkg_link = $p. 'search/cust_pkg.cgi?pkgpart=';
push @fields, sub { my $part_pkg = shift;
- [
- map {
+ [
+ map( {
my $magic = $_;
my $label = $_;
if ( $magic eq 'active' && $part_pkg->freq == 0 ) {
@@ -301,6 +335,7 @@ if ( $acl_edit_global ) {
#$label = 'one-time charge',
$label = 'charge',
}
+ $label= 'not yet billed' if $magic eq 'not_yet_billed';
[
{
@@ -325,8 +360,24 @@ if ( $acl_edit_global ) {
),
},
],
- } (qw( active suspended cancelled ))
- ]; };
+ } (qw( not_yet_billed active suspended cancelled ))
+ ),
+ ($acl_config ?
+ [ {},
+ { 'data' => '<FONT SIZE="-1">[ '.
+ include('/elements/popup_link.html',
+ 'label' => 'change',
+ 'action' => "${p}edit/bulk-cust_pkg.html?".
+ 'pkgpart='.$part_pkg->pkgpart,
+ 'actionlabel' => 'Change Packages',
+ 'width' => 569,
+ 'height' => 210,
+ ).' ]</FONT>',
+ 'align' => 'left',
+ }
+ ] : () ),
+ ];
+ };
$align .= 'r';
#}
diff --git a/httemplate/docs/credits.html b/httemplate/docs/credits.html
index 6500e73..a3e7695 100644
--- a/httemplate/docs/credits.html
+++ b/httemplate/docs/credits.html
@@ -75,6 +75,7 @@ Kelly Hickel<BR>
Mark James<BR>
Frederico Caldeira Knabben<BR>
Greg Kuhnert<BR>
+Erik Levinson<BR>
Randall Lucas<BR>
Foteos Macrides<BR>
Roger Mangraviti<BR>
diff --git a/httemplate/edit/REAL_cust_pkg.cgi b/httemplate/edit/REAL_cust_pkg.cgi
index 77ab1fe..ba217eb 100755
--- a/httemplate/edit/REAL_cust_pkg.cgi
+++ b/httemplate/edit/REAL_cust_pkg.cgi
@@ -55,12 +55,16 @@
<& .row_edit, cust_pkg=>$cust_pkg, column=>'setup', label=>'Setup' &>
<& .row_edit, cust_pkg=>$cust_pkg, column=>'last_bill', label=>$last_bill_or_renewed &>
<& .row_edit, cust_pkg=>$cust_pkg, column=>'bill', label=>$next_bill_or_prepaid_until &>
- <& .row_display, cust_pkg=>$cust_pkg, column=>'adjourn', label=>'Adjournment', note=>'(will <b>suspend</b> this package when the date is reached)' &>
- <& .row_display, cust_pkg=>$cust_pkg, column=>'susp', label=>'Suspension' &>
+%#if ( $cust_pkg->contract_end or $part_pkg->option('contract_end_months',1) ) {
+ <& .row_edit, cust_pkg=>$cust_pkg, column=>'contract_end',label=>'Contract end' &>
+%#}
+ <& .row_display, cust_pkg=>$cust_pkg, column=>'adjourn', label=>'Adjournment', note=>'(will <b>suspend</b> this package when the date is reached)' &>
+ <& .row_display, cust_pkg=>$cust_pkg, column=>'susp', label=>'Suspension' &>
<& .row_display, cust_pkg=>$cust_pkg, column=>'expire', label=>'Expiration', note=>'(will <b>cancel</b> this package when the date is reached)' &>
<& .row_display, cust_pkg=>$cust_pkg, column=>'cancel', label=>'Cancellation' &>
+
<%def .row_edit>
<%args>
$cust_pkg
@@ -133,7 +137,7 @@
my $conf = new FS::Conf;
my $date_format = $conf->config('date_format') || '%m/%d/%Y';
-my $format = $date_format. ' %T %z (%Z)';
+my $format = $date_format. ' %T'; # %z (%Z)';
</%shared>
<%init>
diff --git a/httemplate/edit/access_user.html b/httemplate/edit/access_user.html
index 22cf896..86ce253 100644
--- a/httemplate/edit/access_user.html
+++ b/httemplate/edit/access_user.html
@@ -20,9 +20,8 @@
'user_custnum' => 'Customer (optional)',
'disabled' => 'Disable employee',
},
- 'edit_callback' => sub { my( $c, $o ) = @_;
- $o->set('_password', '');
- },
+ 'edit_callback' => \&edit_callback,
+ 'field_callback'=> \&field_callback,
'viewall_dir' => 'browse',
'html_bottom' =>
sub {
@@ -62,4 +61,17 @@ my $check_user_custnum_search = <<END;
</SCRIPT>
END
+sub edit_callback {
+ my ($c, $o, $f, $opt) = @_;
+ $o->set('_password', '');
+}
+
+sub field_callback {
+ my ($c, $o, $f) = @_;
+ if($f->{'type'} eq 'password' and $o->is_system_user) {
+ $f->{'type'} = 'hidden';
+ $f->{'disabled'} = 1;
+ }
+}
+
</%init>
diff --git a/httemplate/edit/acct_snarf.html b/httemplate/edit/acct_snarf.html
new file mode 100644
index 0000000..1c815b2
--- /dev/null
+++ b/httemplate/edit/acct_snarf.html
@@ -0,0 +1,50 @@
+<% include('elements/edit.html',
+ 'name_singular' => 'remote email address',
+ 'table' => 'acct_snarf',
+ 'labels' => { 'snarfnum' => 'Remote email address',
+ #'svcnum' => 'Local account',
+ 'snarfname' => 'Name',
+ 'machine' => 'Mail server',
+ 'protocol' => 'Protocol',
+ 'username' => 'Username',
+ '_password' => 'Password',
+ 'check_freq' => 'Poll every',
+ 'leavemail' => 'Leave',
+ 'apop' => 'Use APOP',
+ 'tls' => 'TLS',
+ 'mailbox' => 'Mailbox',
+ },
+ 'fields' => [
+ { field=>'svcnum', type=>'hidden', },
+ { field=>'protocol', type=>'hidden', },
+ 'snarfname',
+ 'machine',
+ 'username',
+ { 'field'=>'_password', type=>'password', },
+ { 'field' => 'check_freq',
+ 'type' => 'select',
+ 'options' => [ keys %$cf_labels ],
+ 'labels' => $cf_labels,
+ },
+ { field=>'leavemail', type=>'checkbox', value=>'Y' },
+ { field=>'apop', type=>'checkbox', value=>'Y' },
+ { field=>'tls', type=>'checkbox', value=>'Y' },
+ 'mailbox',
+ ],
+ 'new_callback' => sub { my( $cgi, $acct_snarf ) = @_;
+ $acct_snarf->svcnum($cgi->param('svcnum'));
+ $acct_snarf->protocol('POP');
+ },
+ #'viewall_url' => $viewall_url,
+ 'menubar' => [],
+ )
+%>
+<%init>
+
+my %opt = @_;
+
+#my $viewall_url = $p. "browse/$table.html?svcnum=$svcnum";
+
+my $cf_labels = FS::acct_snarf->check_freq_labels;
+
+</%init>
diff --git a/httemplate/edit/bulk-cust_pkg.html b/httemplate/edit/bulk-cust_pkg.html
new file mode 100644
index 0000000..2ff38ca
--- /dev/null
+++ b/httemplate/edit/bulk-cust_pkg.html
@@ -0,0 +1,60 @@
+<% include('/elements/header-popup.html', 'Bulk package change') %>
+
+<% include('/elements/init_overlib.html') %>
+
+<% include('/elements/progress-init.html',
+ 'OneTrueForm',
+ [qw( old_pkgpart new_pkgpart )],
+ 'process/bulk-cust_pkg.cgi',
+ $p.'browse/part_pkg.cgi',
+ )
+%>
+
+<SCRIPT TYPE="text/javascript">
+function areyousure() {
+ var warning = 'Change these packages?';
+ if(confirm(warning)) {
+ process();
+ }
+}
+</SCRIPT>
+<FORM NAME="OneTrueForm">
+% #false laziness with bulk-cust_svc.html
+% $cgi->param('pkgpart') =~ /^(\d+)$/
+% or die "illegal pkgpart: ". $cgi->param('pkgpart');
+%
+% my $old_pkgpart = $1;
+% my $src_part_pkg = qsearchs('part_pkg', { 'pkgpart' => $old_pkgpart } )
+% or die "unknown pkgpart: $old_pkgpart";
+%
+
+
+<INPUT NAME="old_pkgpart" TYPE="hidden" VALUE="<% $old_pkgpart %>">
+Change <B><% $src_part_pkg->pkg_comment %></B><BR>
+
+to new package definition
+<SELECT NAME="new_pkgpart">
+% foreach my $dest_part_pkg ( qsearch('part_pkg', { 'disabled' => '' } ) ) {
+
+ <OPTION VALUE="<% $dest_part_pkg->pkgpart %>"><% $dest_part_pkg->pkgpart %>: <% $dest_part_pkg->pkg %>
+% }
+
+</SELECT>
+<BR>
+<BR>
+%#<INPUT TYPE="checkbox" NAME="keep_dates" CHECKED> Preserve all billing dates <I>(strongly recommended)</I>
+%#<BR>
+%#<BR>
+
+<INPUT TYPE="button" VALUE="Bulk change packages" onclick="areyousure()">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+</%init>
diff --git a/httemplate/edit/cgp_rule-redirect_all.html b/httemplate/edit/cgp_rule-redirect_all.html
index 898eef8..c8c9e01 100644
--- a/httemplate/edit/cgp_rule-redirect_all.html
+++ b/httemplate/edit/cgp_rule-redirect_all.html
@@ -1,37 +1,49 @@
<% include('/elements/header-popup.html', 'Redirect all mail') %>
+<% include('/elements/error.html') %>
+
<FORM NAME="RedirectAllForm" ACTION="process/cgp_rule-redirect_all.html" METHOD=POST>
-%# XXX upstream Redirect 1
+<INPUT TYPE="hidden" NAME="svcnum" VALUE="<% $opt{'svcnum'} %>">
<% ntable("#cccccc", 2) %>
<TR>
<TD ALIGN="right">Redirect all mail to</TD>
- <TD><textarea name="RedirectText" rows="5" cols="50"></textarea></TD>
+ <TD><textarea name="RedirectText" rows="5" cols="50"><% $mirror_or_redir ? $mirror_or_redir->params : '' %></textarea></TD>
</TR>
<% include('/elements/tr-checkbox.html',
- 'name' => 'RedirKeep',
+ 'field' => 'RedirKeep',
'label' => 'Keep a copy',
'value' => 1,
- 'curr_value' => '', #XXX
+ 'curr_value' => ( $cgi->param('error')
+ ? scalar($cgi->param('RedirKeep'))
+ : ( ($redir_keep || !$cgp_rule) ? '' : 1 )
+ ),
)
%>
<% include('/elements/tr-checkbox.html',
- 'name' => 'RedirHuman',
+ 'field' => 'RedirHuman',
'label' => 'Do not redirect automatic messages',
'value' => 1,
- 'curr_value' => '', #XXX
+ 'curr_value' => ( $cgi->param('error')
+ ? scalar($cgi->param('RedirHuman'))
+ : ( $redir_human ? 1 : '' )
+ ),
)
%>
<% include('/elements/tr-checkbox.html',
- 'name' => 'KeepToAndCc',
+ 'field' => 'KeepToAndCc',
'label' => 'Preserve To/Cc fields',
'value' => 1,
- 'curr_value' => '', #XXX
+ 'curr_value' => ( $cgi->param('error')
+ ? scalar($cgi->param('KeepToAndCc'))
+ : ( $mirror_or_redir &&
+ $mirror_or_redir->action eq 'Mirror To' )
+ ),
)
%>
@@ -39,7 +51,6 @@
<BR>
<INPUT TYPE="submit" VALUE="Redirect all mail">
-%#XXX Add/Edit
</FORM>
@@ -52,6 +63,27 @@ my %opt = @_;
my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $opt{'svcnum'} } )
or die "unknown svcnum";
-#XXX look for existing redirect all rule
+#look for existing rule
+my $cgp_rule = qsearchs('cgp_rule', { 'svcnum' => $svc_acct->svcnum,
+ 'name' => '#Redirect'
+ }
+ );
+
+my( $redir_human, $mirror_or_redir, $redir_keep ) = ( '', '', '' );
+if ( $cgp_rule ) {
+ $redir_human = qsearchs('cgp_rule_condition', {
+ 'rulenum' => $cgp_rule->rulenum,
+ 'conditionname' => 'Human Generated',
+ });
+ $mirror_or_redir = qsearchs({
+ 'table' => 'cgp_rule_action',
+ 'hashref' => { 'rulenum' => $cgp_rule->rulenum, },
+ 'extra_sql' => " AND action IN ('Mirror To', 'Redirect To') ",
+ });
+ $redir_keep = qsearchs('cgp_rule_action', {
+ 'rulenum' => $cgp_rule->rulenum,
+ 'action' => 'Discard',
+ });
+}
</%init>
diff --git a/httemplate/edit/cgp_rule-vacation.html b/httemplate/edit/cgp_rule-vacation.html
index efdc541..8c28885 100644
--- a/httemplate/edit/cgp_rule-vacation.html
+++ b/httemplate/edit/cgp_rule-vacation.html
@@ -1,35 +1,35 @@
<% include('/elements/header-popup.html', 'Vacation rule') %>
+<% include('/elements/error.html') %>
+
<FORM NAME="VacationForm" ACTION="process/cgp_rule-vacation.html" METHOD=POST>
-%# XXX upstream Vacation 1
+<INPUT TYPE="hidden" NAME="svcnum" VALUE="<% $opt{'svcnum'} %>">
<% ntable("#cccccc", 2) %>
<TR>
<TD ALIGN="right">Vacation message</TD>
- <TD><textarea name="VacationText" rows="5" cols="50"></textarea></TD>
+ <TD><textarea name="VacationText" rows="5" cols="50"><% $reply_with ? $reply_with->params : '' %></textarea></TD>
</TR>
<% include('/elements/tr-input-date-field.html', {
- 'label' => 'Ends',
- 'name' => 'vacationTill',
- 'value' => '', #XXX
+ 'label' => 'Ends',
+ 'name' => 'vacationTill',
+ 'format' => '%d %b %Y',
+ 'value' => ( $cgi->param('error')
+ ? scalar($cgi->param('vacationTill'))
+ : ( $curr_date ? $curr_date->params : '' )
+ ),
})
%>
-%# XXX upstream:
-%# VacationTill 1
-%# vacationDay
-%# vacationMonth
-%# vacationYear
-%#XXX Clear 'Replied Addresses' List
+%#Clear 'Replied Addresses' List ?
</TABLE>
<BR>
-<INPUT TYPE="submit" VALUE="Add vacation message">
-%#XXX Add/Edit
+<INPUT TYPE="submit" VALUE="<% $cgp_rule ? 'Edit' : 'Add' %> vacation message">
</FORM>
@@ -42,6 +42,23 @@ my %opt = @_;
my $svc_acct = qsearchs('svc_acct', { 'svcnum' => $opt{'svcnum'} } )
or die "unknown svcnum";
-#XXX look for existing vacation rule
+#look for existing rule
+my $cgp_rule = qsearchs('cgp_rule', { 'svcnum' => $svc_acct->svcnum,
+ 'name' => '#Vacation'
+ }
+ );
+
+my( $curr_date, $reply_with ) = ( '', '' );
+if ( $cgp_rule ) {
+ $curr_date = qsearchs('cgp_rule_condition', {
+ 'rulenum' => $cgp_rule->rulenum,
+ 'conditionname' => 'Current Date',
+ 'op' => 'less than',
+ });
+ $reply_with = qsearchs('cgp_rule_action', {
+ 'rulenum' => $cgp_rule->rulenum,
+ 'action' => 'Reply with',
+ });
+}
</%init>
diff --git a/httemplate/edit/cust_main.cgi b/httemplate/edit/cust_main.cgi
index 57dc359..07629dc 100755
--- a/httemplate/edit/cust_main.cgi
+++ b/httemplate/edit/cust_main.cgi
@@ -179,6 +179,8 @@ function samechanged(what) {
<% include('cust_main/first_pkg.html', $cust_main,
'pkgpart_svcpart' => $pkgpart_svcpart,
+ 'disable_empty' =>
+ scalar( $cgi->param('lock_pkgpart') =~ /^(\d+)$/ ),
#svc_acct
'username' => $username,
'password' => $password,
@@ -307,11 +309,18 @@ if ( $cgi->param('error') ) {
$stateid = '';
$payinfo = '';
+ if ( $cgi->param('lock_pkgpart') =~ /^(\d+)$/ ) {
+ my $pkgpart = $1;
+ my $part_pkg = qsearchs('part_pkg', { 'pkgpart' => $pkgpart } )
+ or die "unknown pkgpart $pkgpart";
+ my $svcpart = $part_pkg->svcpart;
+ $pkgpart_svcpart = $pkgpart.'_'.$svcpart;
+ }
+
}
-my $error = $cgi->param('error');
-$cgi->delete_all();
-$cgi->param('error', $error);
+my %keep = map { $_=>1 } qw( error tagnum lock_agentnum lock_pkgpart );
+$cgi->delete( grep !$keep{$_}, $cgi->param );
my $title = $custnum ? 'Edit Customer' : 'Add Customer';
$title .= ": ". $cust_main->name if $custnum;
diff --git a/httemplate/edit/cust_main/billing.html b/httemplate/edit/cust_main/billing.html
index ad83778..d121982 100644
--- a/httemplate/edit/cust_main/billing.html
+++ b/httemplate/edit/cust_main/billing.html
@@ -240,7 +240,7 @@
% qq!<INPUT TYPE="hidden" NAME="BILL_exp_year" VALUE="2037">!.
%
% qq!<TR><TD ALIGN="right" WIDTH="200">Attention </TD>!.
-% qq!<TD WIDTH="408"><INPUT TYPE="text" NAME="BILL_payname" VALUE="!. ( $payby eq 'BILL' ? $cust_main->payname : '' ). qq!"></TD></TR>!.
+% qq!<TD WIDTH="408"><INPUT TYPE="text" NAME="BILL_payname" VALUE="!. encode_entities( $payby eq 'BILL' ? $cust_main->payname : '' ). qq!"></TD></TR>!.
%
% '<TR><TD>&nbsp;</TD></TR>'.
% '<TR><TD>&nbsp;</TD></TR>'.
@@ -417,6 +417,18 @@
%>
</TD>
</TR>
+ <TR>
+ <TD ALIGN="right" WIDTH="200">Credit limit </TD>
+ <TD WIDTH="408">
+ <SCRIPT TYPE="text/javascript">
+function toggle(obj) {
+ obj.form.credit_limit.disabled = obj.checked;
+}
+ </SCRIPT>
+ <INPUT TYPE="text" NAME="credit_limit" VALUE=<% sprintf('"%.2f"', $cust_main->credit_limit) %><% length($cust_main->credit_limit) ? '' : ' DISABLED' %>>
+ <INPUT TYPE="checkbox" NAME="no_credit_limit" VALUE=1 onclick="toggle(this)"<% length($cust_main->credit_limit) ? '' : ' CHECKED'%>> Unlimited
+ </TD>
+ </TR>
% if ( $conf->exists('voip-cust_cdr_spools') ) {
<TR>
diff --git a/httemplate/edit/cust_main/bottomfixup.js b/httemplate/edit/cust_main/bottomfixup.js
index 5d06f3c..942fc0e 100644
--- a/httemplate/edit/cust_main/bottomfixup.js
+++ b/httemplate/edit/cust_main/bottomfixup.js
@@ -20,223 +20,14 @@ function bottomfixup(what) {
}
//this part does USPS address correction
-
- // XXX should this be first and should we update the form fields that are
- // displayed???
-
- var cf = document.CustomerForm;
-
- var state_el = cf.elements['state'];
- var ship_state_el = cf.elements['ship_state'];
-
- //address_standardize(
- var cust_main = new Array(
- 'company', cf.elements['company'].value,
- 'address1', cf.elements['address1'].value,
- 'address2', cf.elements['address2'].value,
- 'city', cf.elements['city'].value,
- 'state', state_el.options[ state_el.selectedIndex ].value,
- 'zip', cf.elements['zip'].value,
-
- 'ship_company', cf.elements['ship_company'].value,
- 'ship_address1', cf.elements['ship_address1'].value,
- 'ship_address2', cf.elements['ship_address2'].value,
- 'ship_city', cf.elements['ship_city'].value,
- 'ship_state', ship_state_el.options[ ship_state_el.selectedIndex ].value,
- 'ship_zip', cf.elements['ship_zip'].value
- );
-
- address_standardize( cust_main, update_address );
+ standardize_locations();
}
-var standardize_address;
-
-function update_address(arg) {
-
- var argsHash = eval('(' + arg + ')');
-
- var changed = argsHash['address_standardized'];
- var ship_changed = argsHash['ship_address_standardized'];
- var error = argsHash['error'];
- var ship_error = argsHash['ship_error'];
-
-
- //yay closures
- standardize_address = function () {
-
- var cf = document.CustomerForm;
- var state_el = cf.elements['state'];
- var ship_state_el = cf.elements['ship_state'];
-
- if ( changed ) {
- cf.elements['company'].value = argsHash['new_company'];
- cf.elements['address1'].value = argsHash['new_address1'];
- cf.elements['address2'].value = argsHash['new_address2'];
- cf.elements['city'].value = argsHash['new_city'];
- setselect(cf.elements['state'], argsHash['new_state']);
- cf.elements['zip'].value = argsHash['new_zip'];
- }
-
- if ( ship_changed ) {
- cf.elements['ship_company'].value = argsHash['new_ship_company'];
- cf.elements['ship_address1'].value = argsHash['new_ship_address1'];
- cf.elements['ship_address2'].value = argsHash['new_ship_address2'];
- cf.elements['ship_city'].value = argsHash['new_ship_city'];
- setselect(cf.elements['ship_state'], argsHash['new_ship_state']);
- cf.elements['ship_zip'].value = argsHash['new_ship_zip'];
- }
-
- post_standardization();
-
- }
-
-
-
- if ( changed || ship_changed ) {
-
-% if ( $conf->exists('cust_main-auto_standardize_address') ) {
-
- standardize_address();
-
-% } else {
-
- // popup a confirmation popup
-
- var confirm_change =
- '<CENTER><BR><B>Confirm address standardization</B><BR><BR>' +
- '<TABLE>';
-
- if ( changed ) {
-
- confirm_change = confirm_change +
- '<TR><TH>Entered billing address</TH>' +
- '<TH>Standardized billing address</TH></TR>';
- // + '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
-
- if ( argsHash['company'] || argsHash['new_company'] ) {
- confirm_change = confirm_change +
- '<TR><TD>' + argsHash['company'] +
- '</TD><TD>' + argsHash['new_company'] + '</TD></TR>';
- }
-
- confirm_change = confirm_change +
- '<TR><TD>' + argsHash['address1'] +
- '</TD><TD>' + argsHash['new_address1'] + '</TD></TR>' +
- '<TR><TD>' + argsHash['address2'] +
- '</TD><TD>' + argsHash['new_address2'] + '</TD></TR>' +
- '<TR><TD>' + argsHash['city'] + ', ' + argsHash['state'] + ' ' + argsHash['zip'] +
- '</TD><TD>' + argsHash['new_city'] + ', ' + argsHash['new_state'] + ' ' + argsHash['new_zip'] + '</TD></TR>' +
- '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
-
- }
-
- if ( ship_changed ) {
-
- confirm_change = confirm_change +
- '<TR><TH>Entered service address</TH>' +
- '<TH>Standardized service address</TH></TR>';
- // + '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
-
- if ( argsHash['ship_company'] || argsHash['new_ship_company'] ) {
- confirm_change = confirm_change +
- '<TR><TD>' + argsHash['ship_company'] +
- '</TD><TD>' + argsHash['new_ship_company'] + '</TD></TR>';
- }
-
- confirm_change = confirm_change +
- '<TR><TD>' + argsHash['ship_address1'] +
- '</TD><TD>' + argsHash['new_ship_address1'] + '</TD></TR>' +
- '<TR><TD>' + argsHash['ship_address2'] +
- '</TD><TD>' + argsHash['new_ship_address2'] + '</TD></TR>' +
- '<TR><TD>' + argsHash['ship_city'] + ', ' + argsHash['ship_state'] + ' ' + argsHash['ship_zip'] +
- '</TD><TD>' + argsHash['new_ship_city'] + ', ' + argsHash['new_ship_state'] + ' ' + argsHash['new_ship_zip'] + '</TD></TR>' +
- '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
-
- }
-
- var addresses = 'address';
- var height = 268;
- if ( changed && ship_changed ) {
- addresses = 'addresses';
- height = 396; // #what
- }
-
- confirm_change = confirm_change +
- '<TR><TD>' +
- '<BUTTON TYPE="button" onClick="post_standardization();"><IMG SRC="<%$p%>images/error.png" ALT=""> Use entered ' + addresses + '</BUTTON>' +
- '</TD><TD>' +
- '<BUTTON TYPE="button" onClick="standardize_address();"><IMG SRC="<%$p%>images/tick.png" ALT=""> Use standardized ' + addresses + '</BUTTON>' +
- '</TD></TR>' +
- '<TR><TD COLSPAN=2 ALIGN="center">' +
- '<BUTTON TYPE="button" onClick="document.CustomerForm.submitButton.disabled=false; parent.cClick();"><IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission</BUTTON></TD></TR>' +
-
- '</TABLE></CENTER>';
-
- overlib( confirm_change, CAPTION, 'Confirm address standardization', STICKY, AUTOSTATUSCAP, CLOSETEXT, '', MIDX, 0, MIDY, 0, DRAGGABLE, WIDTH, 576, HEIGHT, height, BGCOLOR, '#333399', CGCOLOR, '#333399', TEXTSIZE, 3 );
-
-% }
-
- } else {
-
- post_standardization();
-
- }
-
-
-}
-
-function post_standardization() {
-
- var cf = document.CustomerForm;
-
-% if ( $conf->exists('enable_taxproducts') ) {
-
- if ( new String(cf.elements['<% $taxpre %>zip'].value).length < 10 )
- {
-
- var country_el = cf.elements['<% $taxpre %>country'];
- var country = country_el.options[ country_el.selectedIndex ].value;
- var geocode = cf.elements['geocode'].value;
-
- if ( country == 'CA' || country == 'US' ) {
-
- var state_el = cf.elements['<% $taxpre %>state'];
- var state = state_el.options[ state_el.selectedIndex ].value;
-
- var url = "cust_main/choose_tax_location.html" +
- "?data_vendor=cch-zip" +
- ";city=" + cf.elements['<% $taxpre %>city'].value +
- ";state=" + state +
- ";zip=" + cf.elements['<% $taxpre %>zip'].value +
- ";country=" + country +
- ";geocode=" + geocode +
- ";";
-
- // popup a chooser
- OLgetAJAX( url, update_geocode, 300 );
-
- } else {
-
- cf.elements['geocode'].value = 'DEFAULT';
- post_geocode();
-
- }
-
- } else {
-
- cf.elements['geocode'].value = '';
- post_geocode();
-
- }
-
-% } else {
-
- post_geocode();
-
-% }
-
-}
+<% include( '/elements/standardize_locations.js',
+ 'callback', 'post_geocode();'
+ )
+%>
function post_geocode() {
@@ -263,29 +54,6 @@ function post_geocode() {
}
-function update_geocode() {
-
- //yay closures
- set_geocode = function (what) {
-
- var cf = document.CustomerForm;
-
- //alert(what.options[what.selectedIndex].value);
- var argsHash = eval('(' + what.options[what.selectedIndex].value + ')');
- cf.elements['<% $taxpre %>city'].value = argsHash['city'];
- setselect(cf.elements['<% $taxpre %>state'], argsHash['state']);
- cf.elements['<% $taxpre %>zip'].value = argsHash['zip'];
- cf.elements['geocode'].value = argsHash['geocode'];
- post_geocode();
-
- }
-
- // popup a chooser
-
- overlib( OLresponseAJAX, CAPTION, 'Select tax location', STICKY, AUTOSTATUSCAP, CLOSETEXT, '', MIDX, 0, MIDY, 0, DRAGGABLE, WIDTH, 576, HEIGHT, 268, BGCOLOR, '#333399', CGCOLOR, '#333399', TEXTSIZE, 3 );
-
-}
-
var set_censustract;
function update_censustract(arg) {
@@ -381,19 +149,8 @@ function copyelement(from, to) {
//alert(from + " (" + from.type + "): " + to.name + " => " + to.value);
}
-function setselect(el, value) {
-
- for ( var s = 0; s < el.options.length; s++ ) {
- if ( el.options[s].value == value ) {
- el.selectedIndex = s;
- }
- }
-
-}
<%init>
my $conf = new FS::Conf;
-my $taxpre = $conf->exists('tax-ship_address') ? 'ship_' : '';
-
</%init>
diff --git a/httemplate/edit/cust_main/contact.html b/httemplate/edit/cust_main/contact.html
index feb61db..99bc558 100644
--- a/httemplate/edit/cust_main/contact.html
+++ b/httemplate/edit/cust_main/contact.html
@@ -3,8 +3,8 @@
<TR>
<TH ALIGN="right"><%$r%>Contact&nbsp;name<BR>(last,&nbsp;first)</TH>
<TD COLSPAN=5>
- <INPUT TYPE="text" NAME="<%$pre%>last" VALUE="<% $cust_main->get($pre.'last') %>" onChange="<% $onchange %>" <%$disabled%> <%$style%>> ,
- <INPUT TYPE="text" NAME="<%$pre%>first" VALUE="<% $cust_main->get($pre.'first') %>" onChange="<% $onchange %>" <%$disabled%> <%$style%>>
+ <INPUT TYPE="text" NAME="<%$pre%>last" VALUE="<% $cust_main->get($pre.'last') |h %>" onChange="<% $onchange %>" <%$disabled%> <%$style%>> ,
+ <INPUT TYPE="text" NAME="<%$pre%>first" VALUE="<% $cust_main->get($pre.'first') |h %>" onChange="<% $onchange %>" <%$disabled%> <%$style%>>
</TD>
% if ( $conf->exists('show_ss') && !$pre ) {
@@ -21,7 +21,7 @@
<TR>
<TD ALIGN="right">Company</TD>
<TD COLSPAN=7>
- <INPUT TYPE="text" NAME="<%$pre%>company" VALUE="<% $cust_main->get($pre.'company') %>" SIZE=70 onChange="<% $onchange %>" <%$disabled%> <%$style%>>
+ <INPUT TYPE="text" NAME="<%$pre%>company" VALUE="<% $cust_main->get($pre.'company') |h %>" SIZE=70 onChange="<% $onchange %>" <%$disabled%> <%$style%>>
</TD>
</TR>
diff --git a/httemplate/edit/cust_main/first_pkg.html b/httemplate/edit/cust_main/first_pkg.html
index 0de33c0..7c131ea 100644
--- a/httemplate/edit/cust_main/first_pkg.html
+++ b/httemplate/edit/cust_main/first_pkg.html
@@ -1,3 +1,9 @@
+% if ( $cgi->param('lock_pkgpart') =~ /^([\d, ]+)$/ ) {
+
+ <INPUT TYPE="hidden" NAME="lock_pkgpart" VALUE="<% $1 %>">
+
+% }
+%
% if ( @part_pkg ) {
<BR><BR>
@@ -28,6 +34,11 @@ if ( scalar(@agents) == 1 ) {
# $pkgpart->{PKGPART} is true iff $custnum may purchase PKGPART
$pkgpart = $agents[0]->pkgpart_hashref;
$agentnum = $agents[0]->agentnum;
+} elsif ( $cgi->param('lock_agentnum') =~ /^(\d+)$/
+ && $FS::CurrentUser::CurrentUser->agentnum($1) ) {
+ $agentnum = $1;
+ my $agent = (grep { $_->agentnum == $agentnum } @agents)[0];
+ $pkgpart = $agent->pkgpart_hashref;
} else {
#can't know (agent not chosen), so, allow all
$agentnum = 'all';
@@ -39,9 +50,28 @@ if ( scalar(@agents) == 1 ) {
}
#eslaf
+my @part_pkg = ();
+if ( $cgi->param('lock_pkgpart') =~ /^([\d, ]+)$/ ) {
+
+ my $lock_pkgpart = $1;
+
+ @part_pkg = qsearch({
+ 'table' => 'part_pkg',
+ 'hashref' => { 'disabled' => '' },
+ 'extra_sql' => "AND pkgpart IN ($lock_pkgpart)",
+ 'order_by' => 'ORDER BY pkg', # case?
+ });
+
+} else {
+
+ @part_pkg =
+ qsearch( 'part_pkg', { 'disabled' => '' }, '', 'ORDER BY pkg' ); # case?
+
+}
+
my @first_svc = ( 'svc_acct', 'svc_phone' );
-my @part_pkg =
+@part_pkg =
grep { $_->svcpart(\@first_svc)
&& ( $pkgpart->{ $_->pkgpart }
|| $agentnum eq 'all'
@@ -50,6 +80,6 @@ my @part_pkg =
)
)
}
- qsearch( 'part_pkg', { 'disabled' => '' }, '', 'ORDER BY pkg' ); # case?
+ @part_pkg;
</%init>
diff --git a/httemplate/edit/cust_main/first_pkg/select-part_pkg.html b/httemplate/edit/cust_main/first_pkg/select-part_pkg.html
index 871e1cd..20f0e19 100644
--- a/httemplate/edit/cust_main/first_pkg/select-part_pkg.html
+++ b/httemplate/edit/cust_main/first_pkg/select-part_pkg.html
@@ -147,7 +147,9 @@ foreach my $part_pkg ( @part_pkg ) {
$layermap{$pkgpart_svcpart} = $svcdb{$pkgpart};
}
-my @options = ( '', map $pkgpart_svcpart{ $_->pkgpart }, @part_pkg );
+my @options = ();
+push @options, '' unless $opt{'disable_empty'};
+push @options, map $pkgpart_svcpart{ $_->pkgpart }, @part_pkg;
my %labels = ( '' => ( $opt{'empty_label'} || '(none)' ),
map { $pkgpart_svcpart{ $_->pkgpart } => $_->pkg_comment }
@part_pkg
diff --git a/httemplate/edit/cust_main/top_misc.html b/httemplate/edit/cust_main/top_misc.html
index 441a363..a2381f3 100644
--- a/httemplate/edit/cust_main/top_misc.html
+++ b/httemplate/edit/cust_main/top_misc.html
@@ -8,14 +8,30 @@
%>
%# agent
-<% include('/elements/tr-select-agent.html',
- 'curr_value' => $cust_main->agentnum,
- 'label' => "<B>${r}Agent</B>",
- 'empty_label' => 'Select agent',
- 'disable_empty' => ( $cust_main->agentnum ? 1 : 0 ),
- 'viewall_right' => 'None', #override default 'View customers of all agents'
- )
-%>
+% if ( $cgi->param('lock_agentnum') =~ /^(\d+)$/ && $curuser->agentnum($1) ) {
+%
+% my $agentnum = $1;
+% $cust_main->agentnum($agentnum);
+
+ <INPUT TYPE="hidden" NAME="lock_agentnum" VALUE="<% $agentnum %>">
+ <INPUT TYPE="hidden" NAME="agentnum" VALUE="<% $agentnum %>">
+ <TR>
+ <TD ALIGN="right">Agent</TD>
+ <TD CLASS="fsdisabled"><% $cust_main->agent->agent |h %></TD>
+ </TR>
+
+% } else {
+
+ <% include('/elements/tr-select-agent.html',
+ 'curr_value' => $cust_main->agentnum,
+ 'label' => "<B>${r}Agent</B>",
+ 'empty_label' => 'Select agent',
+ 'disable_empty' => ( $cust_main->agentnum ? 1 : 0 ),
+ 'viewall_right' => 'None', #override default 'View customers of all agents'
+ )
+ %>
+
+% }
%# agent_custid
% if ( $conf->exists('cust_main-edit_agent_custid') ) {
diff --git a/httemplate/edit/cust_pay.cgi b/httemplate/edit/cust_pay.cgi
index cc4ec60..8e1c779 100755
--- a/httemplate/edit/cust_pay.cgi
+++ b/httemplate/edit/cust_pay.cgi
@@ -46,6 +46,12 @@ Payment
<TD><INPUT TYPE="text" NAME="paid" VALUE="<% $paid %>" SIZE=8 MAXLENGTH=8> by <B><% FS::payby->payname($payby) %></B></TD>
</TR>
+ <% include('/elements/tr-select-discount_term.html',
+ 'custnum' => $custnum,
+ 'cgi' => $cgi
+ )
+ %>
+
% if ( $payby eq 'BILL' ) {
<TR>
<TD ALIGN="right">Check #</TD>
@@ -103,9 +109,6 @@ my $conf = new FS::Conf;
my $money_char = $conf->config('money_char') || '$';
my $date_format = $conf->config('date_format') || '%m/%d/%Y';
-die "access denied"
- unless $FS::CurrentUser::CurrentUser->access_right('Post payment');
-
my($link, $linknum, $paid, $payby, $payinfo, $_date);
if ( $cgi->param('error') ) {
$link = $cgi->param('link');
@@ -132,6 +135,13 @@ if ( $cgi->param('error') ) {
die "illegal query ". $cgi->keywords;
}
+my @rights = ('Post payment');
+push @rights, 'Post check payment' if $payby eq 'BILL';
+push @rights, 'Post cash payment' if $payby eq 'CASH';
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right(\@rights);
+
my $paybatch = "webui-$_date-$$-". rand() * 2**32;
my $title = 'Post '. FS::payby->payname($payby). ' payment';
diff --git a/httemplate/edit/cust_pay_pending.html b/httemplate/edit/cust_pay_pending.html
index 0916a1c..0056bb9 100644
--- a/httemplate/edit/cust_pay_pending.html
+++ b/httemplate/edit/cust_pay_pending.html
@@ -8,6 +8,10 @@
<CENTER><FONT SIZE="+1"><B>No response was received from <% $cust_pay_pending->processor || 'the payment gateway' %> for this transaction. Check <% $cust_pay_pending->processor || 'the payment gateway' %>'s reporting and determine if this transaction completed successfully.</B></FONT></CENTER>
+% } elsif ( $action eq 'capture' ) {
+
+ <CENTER><FONT SIZE="+1"><B>Captured payment not recorded in database - check logs for errors.</B></FONT></CENTER>
+
% }
<BR>
@@ -91,7 +95,9 @@
</TD>
</TR>
-% } elsif ( $action eq 'complete' ) {
+% } else {
+
+%# if ( $action eq 'complete' ) {
<INPUT TYPE="hidden" NAME="action" VALUE="">
@@ -99,15 +105,18 @@
<TD ALIGN="center">
<BUTTON TYPE="button" onClick="document.pendingform.action.value = 'insert_cust_pay'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/tick.png" ALT=""-->Yes, transaction completed sucessfully.</BUTTON>
</TD>
- <TD>&nbsp;&nbsp;&nbsp;</TD>
- <TD ALIGN="center">
- <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'decline'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was declined</BUTTON>
- </TD>
- <TD>&nbsp;&nbsp;&nbsp;</TD>
- <TD ALIGN="center">
- <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'delete'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was not received</BUTTON>
- </TD>
- </TR>
+
+% if ( $action eq 'complete' ) {
+ <TD>&nbsp;&nbsp;&nbsp;</TD>
+ <TD ALIGN="center">
+ <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'decline'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was declined</BUTTON>
+ </TD>
+ <TD>&nbsp;&nbsp;&nbsp;</TD>
+ <TD ALIGN="center">
+ <BUTTON TYPE="button" onClick="document.pendingform.action.value = 'delete'; document.pendingform.submit();"><!--IMG SRC="<%$p%>images/cross.png" ALT=""-->No, transaction was not received</BUTTON>
+ </TD>
+ </TR>
+% }
<TR><TD COLSPAN=5></TD></TR>
diff --git a/httemplate/edit/cust_refund.cgi b/httemplate/edit/cust_refund.cgi
index 59417b4..612e337 100755
--- a/httemplate/edit/cust_refund.cgi
+++ b/httemplate/edit/cust_refund.cgi
@@ -130,9 +130,6 @@
<%init>
-die "access denied"
- unless $FS::CurrentUser::CurrentUser->access_right('Refund payment');
-
my $conf = new FS::Conf;
my $date_format = $conf->config('date_format') || '%m/%d/%Y';
@@ -143,6 +140,17 @@ my $payinfo = $cgi->param('payinfo');
my $reason = $cgi->param('reason');
my $link = $cgi->param('popup') ? 'popup' : '';
+my @rights = ();
+push @rights, 'Post refund' if $payby /^(BILL|CASH)$/;
+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)$/;
+push @rights, 'Refund credit card payment' if $payby eq 'CARD';
+push @rights, 'Refund Echeck payment' if $payby eq 'CHEK';
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right(\@rights);
+
my( $paynum, $cust_pay ) = ( '', '' );
if ( $cgi->param('paynum') =~ /^(\d+)$/ ) {
$paynum = $1;
diff --git a/httemplate/edit/domain_record.html b/httemplate/edit/domain_record.html
new file mode 100644
index 0000000..3ea6c77
--- /dev/null
+++ b/httemplate/edit/domain_record.html
@@ -0,0 +1,53 @@
+<% include('/elements/header-popup.html', 'Edit nameservice record') %>
+
+<% include('/elements/error.html') %>
+
+<FORM METHOD="POST" ACTION="process/domain_record.cgi">
+
+<INPUT TYPE="hidden" NAME="recnum" VALUE="<% $opt{'recnum'} %>">
+
+<% ntable("#cccccc", 2) %>
+
+ <tr>
+ <td>
+ <INPUT TYPE="text" NAME="reczone" VALUE="<% $domain_record->reczone %>">
+ <BR>
+ <FONT SIZE="-1"><I>Zone</I></FONT>
+ </TD>
+ <TD>
+ <INPUT TYPE="hidden" NAME="recaf" VALUE="IN">
+ <SELECT NAME="rectype">
+% foreach ( @{ FS::domain_record->rectypes } ) {
+ <OPTION VALUE="<%$_%>"
+ <% $_ eq $domain_record->rectype ? 'SELECTED' : '' %>
+ >IN <%$_%></OPTION>
+% }
+ </SELECT><BR>
+ <FONT SIZE="-1"><I>Type</I></FONT>
+ </TD>
+ <TD>
+ <INPUT TYPE="text" NAME="recdata" VALUE="<% $domain_record->recdata |h %>">
+ <BR>
+ <FONT SIZE="-1"><I>Data</I></FONT>
+ </TD>
+ <TD>
+ <INPUT TYPE="text" NAME="ttl" size="6" VALUE="<% $domain_record->ttl %>">
+ <BR>
+ <FONT SIZE="-1"><I>TTL</I></FONT>
+ </TD>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Edit record">
+
+</FORM>
+
+<%init>
+
+my %opt = @_;
+
+my $domain_record = qsearchs('domain_record', { 'recnum' => $opt{'recnum'} } )
+ or die "unknown recnum";
+
+</%init>
diff --git a/httemplate/edit/elements/edit.html b/httemplate/edit/elements/edit.html
index b19b361..3d82847 100644
--- a/httemplate/edit/elements/edit.html
+++ b/httemplate/edit/elements/edit.html
@@ -292,7 +292,11 @@ Example:
%
% #select-table
% $include_common{$_} = $f->{$_}
-% foreach grep exists($f->{$_}), qw( value_col extra_sql );
+% foreach grep exists($f->{$_}), qw( value_col );
+% $include_common{$_} = ref( $f->{$_} ) eq 'CODE'
+% ? &{ $f->{$_} }( $cgi, $object ) #, $f )
+% : $f->{$_}
+% foreach grep exists($f->{$_}), qw( extra_sql );
%
% #select-table, checkboxes-table
% $include_common{$_} = $f->{$_}
diff --git a/httemplate/edit/msg_template.html b/httemplate/edit/msg_template.html
index 67eae18..be917d6 100644
--- a/httemplate/edit/msg_template.html
+++ b/httemplate/edit/msg_template.html
@@ -5,17 +5,19 @@
'viewall_dir' => 'browse',
'agent_virt' => 1,
'agent_null' => 1,
- 'agent_null_right' => 'Edit global templates',
+ 'agent_null_right' => ['Edit global templates', 'Configuration'],
'fields' => [ 'msgname',
- 'subject',
- 'from_addr',
+ { field=>'from_addr', size=>60, },
+ { field=>'bcc_addr', size=>60, },
+ { field=>'subject', size=>80, },
{ field=>'body', type=>'htmlarea', width=>763 },
],
'labels' => { 'msgnum' => 'Template',
'msgname' => 'Template name',
- 'from_addr' => 'Return address',
- 'subject' => 'Message subject',
+ 'from_addr' => 'From: ',
+ 'bcc_addr' => 'Bcc: ',
+ 'subject' => 'Subject: ',
'body' => 'Message template',
},
'html_foot' => "</TD>$sidebar</TR></TABLE>",
@@ -46,11 +48,13 @@ my %substitutions = (
'$classname' => 'Customer class',
'$categoryname' => 'Customer category',
'$balance' => 'Current balance',
+ '$credit_limit' => 'Credit limit',
'$invoicing_list_emailonly' => 'Billing email address',
'$cust_status' => 'Status',
'$ucfirst_cust_status' => 'Status, capitalized',
'$cust_statuscolor' => 'Status color code',
'$company_name' => 'Our company name',
+ '$company_address'=> 'Our company address',
],
'contact' => [ # duplicate this for shipping
'$name' => 'Company and contact name',
@@ -76,8 +80,8 @@ my %substitutions = (
],
'cust_pkg' => [
'$pkgnum' => 'Package#',
- '$pkg_label' => 'Package label (short)',
- '$pkg_label_long' => 'Package label (long)',
+ '$pkg' => 'Package description',
+ '$pkg_label' => 'Description + comment',
'$status' => 'Status',
'$statuscolor' => 'Status color code',
'$start_ymd' => 'Start date',
@@ -92,8 +96,10 @@ my %substitutions = (
'$location_label' => 'Service location',
],
'svc_acct' => [
+ '$svcnum' => 'Service#',
'$username' => 'Login name',
'$password' => 'Password',
+ '$domain' => 'Domain name',
],
'cust_pay' => [
'$paynum' => 'Payment#',
@@ -101,6 +107,7 @@ my %substitutions = (
'$payby' => 'Payment method',
'$date' => 'Payment date',
'$payinfo' => 'Card/account# (masked)',
+ '$error' => 'Decline reason',
],
);
my @c = @{ $substitutions{'contact'} };
diff --git a/httemplate/edit/part_export.cgi b/httemplate/edit/part_export.cgi
index 8b697e1..a2fad56 100644
--- a/httemplate/edit/part_export.cgi
+++ b/httemplate/edit/part_export.cgi
@@ -77,7 +77,16 @@ my $widget = new HTML::Widgets::SelectLayers(
? $optinfo->{default}
: ''
);
- $html .= qq!<TR><TD ALIGN="right">$label</TD><TD>!;
+ # 'freeform': disables table formatting of options. Instead, each
+ # option can define "before" and "after" strings which are inserted
+ # around the selector.
+ my $freeform = $optinfo->{freeform};
+ if ( $freeform ) {
+ $html .= $optinfo->{before} || '';
+ }
+ else {
+ $html .= qq!<TR><TD ALIGN="right">$label</TD><TD>!;
+ }
if ( $type eq 'select' ) {
my $size = defined($optinfo->{size}) ? " SIZE=" . $optinfo->{size} : '';
my $multi = defined($optinfo->{multi}) ? ' MULTIPLE' : '';
@@ -108,7 +117,7 @@ my $widget = new HTML::Widgets::SelectLayers(
$html .= qq!<TEXTAREA NAME="$option" COLS=80 ROWS=8 WRAP="virtual">!.
encode_entities($value). '</TEXTAREA>';
} elsif ( $type eq 'text' ) {
- $html .= qq!<INPUT TYPE="text" NAME="$option" VALUE="!.
+ $html .= qq!<INPUT TYPE="text" NAME="$option" VALUE="!. #"
encode_entities($value). '" SIZE=64>';
} elsif ( $type eq 'checkbox' ) {
$html .= qq!<INPUT TYPE="checkbox" NAME="$option" VALUE="1"!;
@@ -117,7 +126,12 @@ my $widget = new HTML::Widgets::SelectLayers(
} else {
$html .= "unknown type $type";
}
- $html .= '</TD></TR>';
+ if ( $freeform ) {
+ $html .= $optinfo->{after} || '';
+ }
+ else {
+ $html .= '</TD></TR>';
+ }
}
$html .= '</TABLE>';
diff --git a/httemplate/edit/part_pkg.cgi b/httemplate/edit/part_pkg.cgi
index deefa9c..be8b0f6 100755
--- a/httemplate/edit/part_pkg.cgi
+++ b/httemplate/edit/part_pkg.cgi
@@ -45,6 +45,7 @@
'agentnum' => 'Agent',
'setup_fee' => 'Setup fee',
'recur_fee' => 'Recurring fee',
+ 'discountnum' => 'Offer discounts for longer terms',
'bill_dst_pkgpart' => 'Include line item(s) from package',
'svc_dst_pkgpart' => 'Include services of package',
'report_option' => 'Report classes',
@@ -94,6 +95,7 @@
type => 'selectlayers-select',
options => [ keys %plan_labels ],
labels => \%plan_labels,
+ onchange => 'aux_planchanged(what);',
},
{ field => 'setup_fee',
type => 'money',
@@ -195,6 +197,21 @@
'multiple' => 1,
},
+ { 'type' => 'tablebreak-tr-title',
+ 'value' => 'Term discounts',
+ },
+ { 'field' => 'discountnum',
+ 'type' => 'select-table',
+ 'table' => 'discount',
+ 'name_col' => 'name',
+ 'hashref' => { %$discountnum_hashref },
+ #'extra_sql' => 'AND (months IS NOT NULL OR months != 0)',
+ 'empty_label'=> 'Select discount',
+ 'm2_label' => 'Offer discounts for longer terms',
+ 'm2m_method' => 'part_pkg_discount',
+ 'm2m_dstcol' => 'discountnum',
+ 'm2_error_callback' => $discount_error_callback,
+ },
{ 'type' => 'tablebreak-tr-title',
'value' => 'Pricing add-ons',
@@ -202,6 +219,10 @@
},
{ 'field' => 'bill_dst_pkgpart',
'type' => 'select-part_pkg',
+ 'extra_sql' => sub { $pkgpart
+ ? "AND pkgpart != $pkgpart"
+ : ''
+ },
'm2_label' => 'Include line item(s) from package',
'm2m_method' => 'bill_part_pkg_link',
'm2m_dstcol' => 'dst_pkgpart',
@@ -224,6 +245,10 @@
{ 'field' => 'svc_dst_pkgpart',
'label' => 'Also include services from package: ',
'type' => 'select-part_pkg',
+ 'extra_sql' => sub { $pkgpart
+ ? "AND pkgpart != $pkgpart"
+ : ''
+ },
'm2_label' => 'Include services of package: ',
'm2m_method' => 'svc_part_pkg_link',
'm2m_dstcol' => 'dst_pkgpart',
@@ -300,6 +325,8 @@ my @taxproductnums = ( qw( setup recur ), sort (keys %taxproductnums) );
my %options = ();
my $recur_disabled = 1;
+my $pkgpart = '';
+
my $error_callback = sub {
my($cgi, $object, $fields, $opt ) = @_;
@@ -333,6 +360,8 @@ my $error_callback = sub {
$object->set($_ => scalar($cgi->param($_)) )
foreach (qw( setup_fee recur_fee ));
+ $pkgpart = $object->pkgpart;
+
};
my $new_hashref_callback = sub { { 'plan' => 'flat' }; };
@@ -382,17 +411,22 @@ my $edit_callback = sub {
$object->set($_ => $object->option($_))
foreach (qw( setup_fee recur_fee ));
+ $pkgpart = $object->pkgpart;
+
};
my $new_callback = sub {
my( $cgi, $object, $fields ) = @_;
my $conf = new FS::Conf;
+
if ( $conf->exists('agent_defaultpkg') ) {
#my @all_agent_types = map {$_->typenum} qsearch('agent_type',{});
@agent_type = map {$_->typenum} qsearch('agent_type',{});
}
+ $options{'suspend_bill'}=1 if $conf->exists('part_pkg-default_suspend_bill');
+
};
my $clone_callback = sub {
@@ -426,6 +460,23 @@ my $clone_callback = sub {
$recur_disabled = $object->freq ? 0 : 1;
};
+my $discount_error_callback = sub {
+ my( $cgi, $object ) = @_;
+ map {
+ if ( /^discountnum(\d+)$/ &&
+ ( my $discountnum = $cgi->param("discountnum$1") ) )
+ {
+ new FS::part_pkg_discount {
+ 'pkgpart' => $object->pkgpart,
+ 'discountnum' => $discountnum,
+ };
+ } else {
+ ();
+ }
+ }
+ $cgi->param;
+};
+
my $m2_error_callback_maker = sub {
my $link_type = shift; #yay closures
return sub {
@@ -484,6 +535,22 @@ my $javascript = <<'END';
}
+ function aux_planchanged(what) {
+
+ alert('called!');
+ var plan = what.options[what.selectedIndex].value;
+ var table = document.getElementById('TableNumber7') // XXX NOT ROBUST
+
+ if ( plan == 'flat' || plan == 'prorate' || plan == 'subscription' ) {
+ //table.disabled = false;
+ table.style.visibility = '';
+ } else {
+ //table.disabled = true;
+ table.style.visibility = 'hidden';
+ }
+
+ }
+
</SCRIPT>
END
@@ -736,4 +803,9 @@ my $field_callback = sub {
}
};
+my $discountnum_hashref = {
+ 'disabled' => '',
+ 'months' => { 'op' => '>', 'value' => 1 },
+ };
+
</%init>
diff --git a/httemplate/edit/part_svc.cgi b/httemplate/edit/part_svc.cgi
index 6fe015a..940ea8d 100755
--- a/httemplate/edit/part_svc.cgi
+++ b/httemplate/edit/part_svc.cgi
@@ -291,12 +291,22 @@ that field.
% (grep(/^$rvalue$/, split(',',$value)) ? ' SELECTED>' : '>' ).
% $record->$select_label(). '</OPTION>';
% } #next $record
-% } else { # select_list
+% } elsif ( $def->{select_list} ) {
% foreach my $item ( @{$def->{select_list}} ) {
% $html .= qq!<OPTION VALUE="$item"!.
% (grep(/^$item$/, split(',',$value)) ? ' SELECTED>' : '>' ).
% $item. '</OPTION>';
% } #next $item
+% } elsif ( $def->{select_hash} ) {
+% if ( ref($def->{select_hash}) eq 'ARRAY' ) {
+% tie my %hash, 'Tie::IxHash', @{ $def->{select_hash} };
+% $def->{select_hash} = \%hash;
+% }
+% foreach my $key ( keys %{$def->{select_hash}} ) {
+% $html .= qq!<OPTION VALUE="$key"!.
+% (grep(/^$key$/, split(',',$value)) ? ' SELECTED>' : '>' ).
+% $def->{select_hash}{$key}. '</OPTION>';
+% } #next $key
% } #endif
% $html .= '</SELECT>';
%
diff --git a/httemplate/edit/process/REAL_cust_pkg.cgi b/httemplate/edit/process/REAL_cust_pkg.cgi
index 570f0e0..3a62ee0 100755
--- a/httemplate/edit/process/REAL_cust_pkg.cgi
+++ b/httemplate/edit/process/REAL_cust_pkg.cgi
@@ -20,7 +20,7 @@ my $pkgnum = $cgi->param('pkgnum') or die;
my $old = qsearchs('cust_pkg',{'pkgnum'=>$pkgnum});
my %hash = $old->hash;
$hash{$_}= $cgi->param($_) ? parse_datetime($cgi->param($_)) : ''
- foreach qw( start_date setup bill last_bill adjourn expire );
+ foreach qw( start_date setup bill last_bill adjourn expire contract_end );
my @errors = ();
diff --git a/httemplate/edit/process/access_user.html b/httemplate/edit/process/access_user.html
index e6258a9..8e7e70a 100644
--- a/httemplate/edit/process/access_user.html
+++ b/httemplate/edit/process/access_user.html
@@ -10,6 +10,7 @@
'process_m2m' => { 'link_table' => 'access_usergroup',
'target_table' => 'access_group',
},
+ 'precheck_callback'=> \&precheck_callback,
)
%>
% }
@@ -23,4 +24,13 @@ if ( FS::Conf->new->exists('disable_acl_changes') ) {
die "shouldn't be reached";
}
+sub precheck_callback {
+ my $cgi = shift;
+ my $o = FS::access_user->new({username => $cgi->param('username')});
+ if( $o->is_system_user and !$cgi->param('usernum') ) {
+ $cgi->param('username','');
+ return "username '".$o->username."' reserved for system account."
+ }
+ return '';
+}
</%init>
diff --git a/httemplate/edit/process/acct_snarf.html b/httemplate/edit/process/acct_snarf.html
new file mode 100644
index 0000000..332ac52
--- /dev/null
+++ b/httemplate/edit/process/acct_snarf.html
@@ -0,0 +1,20 @@
+<% include( 'elements/process.html',
+ 'table' => 'acct_snarf',
+ 'redirect' => $redirect,
+ 'noerror_callback' => sub {
+ my( $cgi, $object ) = @_;
+ my $error = $object->svc_export;
+ #shit, not a good place for error handling :/
+ die $error if $error;
+ },
+ )
+%>
+<%init>
+
+my $redirect = sub {
+ my($cgi, $new) = @_;
+ my $svcnum = $new->svcnum;
+ popurl(3)."browse/acct_snarf.html?svcnum=$svcnum;snarfnum=";
+};
+
+</%init>
diff --git a/httemplate/edit/process/bulk-cust_pkg.cgi b/httemplate/edit/process/bulk-cust_pkg.cgi
new file mode 100644
index 0000000..ede3ee8
--- /dev/null
+++ b/httemplate/edit/process/bulk-cust_pkg.cgi
@@ -0,0 +1,9 @@
+<% $server->process %>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $server = new FS::UI::Web::JSRPC 'FS::cust_pkg::process_bulk_cust_pkg', $cgi;
+
+</%init>
diff --git a/httemplate/edit/process/cgp_rule-redirect_all.html b/httemplate/edit/process/cgp_rule-redirect_all.html
new file mode 100644
index 0000000..162d857
--- /dev/null
+++ b/httemplate/edit/process/cgp_rule-redirect_all.html
@@ -0,0 +1,24 @@
+<% include('cgp_rule-simplified.html',
+ 'name' => '#Redirect',
+ 'priority' => 1,
+ 'redirect' => 'cgp_rule-redirect_all.html',
+ 'conditions' => [
+ ( $cgi->param('RedirHuman')
+ ? { conditionname => 'Human Generated', }
+ : ()
+ ),
+ ],
+ 'actions' => [
+ { action => ( $cgi->param('KeepToAndCc')
+ ? 'Mirror To'
+ : 'Redirect To'
+ ),
+ params => scalar($cgi->param('RedirectText')),
+ },
+ ( $cgi->param('RedirKeep')
+ ? ()
+ : ( { 'action' => 'Discard' } )
+ ),
+ ],
+ )
+%>
diff --git a/httemplate/edit/process/cgp_rule-simplified.html b/httemplate/edit/process/cgp_rule-simplified.html
new file mode 100644
index 0000000..60769d4
--- /dev/null
+++ b/httemplate/edit/process/cgp_rule-simplified.html
@@ -0,0 +1,53 @@
+% if ( $error ) { #redirect back to edit...
+% $cgi->param('error', $error);
+<% $cgi->redirect(popurl(3).'edit/'.$opt{'redirect'}.'?'. $cgi->query_string) %>
+% } else { #success XXX better msg talking about vacation vs. redirect all
+ <% include('/elements/header-popup.html', 'Rule updated') %>
+ <SCRIPT TYPE="text/javascript">
+ window.top.location.reload();
+ </SCRIPT>
+
+ </BODY>
+ </HTML>
+% }
+<%init>
+
+my %opt = @_;
+
+my %hash = (
+ 'svcnum' => scalar($cgi->param('svcnum')),
+ 'name' => $opt{'name'},
+);
+
+my $cgp_rule = qsearchs('cgp_rule', \%hash);
+
+my $error = '';
+if ( $cgp_rule ) { #updating
+ $error = $cgp_rule->delete;
+}
+
+$cgp_rule = new FS::cgp_rule { %hash, 'priority' => $opt{'priority'} };
+$error ||= $cgp_rule->insert;
+
+foreach my $condition ( @{ $opt{'conditions'} } ) {
+ my $cgp_rule_condition = new FS::cgp_rule_condition {
+ %$condition,
+ 'rulenum' => $cgp_rule->rulenum,
+ };
+ $error ||= $cgp_rule_condition->insert;
+}
+
+foreach my $action ( @{ $opt{'actions'} } ) {
+ my $cgp_rule_action = new FS::cgp_rule_action {
+ %$action,
+ 'rulenum' => $cgp_rule->rulenum,
+ };
+ $error ||= $cgp_rule_action->insert;
+}
+
+unless ( $error ) {
+ my $export_error = $cgp_rule->svc_export;
+ die $export_error if $export_error; #error handling sucks wrt this... shouldn't happen though
+}
+
+</%init>
diff --git a/httemplate/edit/process/cgp_rule-vacation.html b/httemplate/edit/process/cgp_rule-vacation.html
new file mode 100644
index 0000000..f10d72b
--- /dev/null
+++ b/httemplate/edit/process/cgp_rule-vacation.html
@@ -0,0 +1,29 @@
+<% include('cgp_rule-simplified.html',
+ 'name' => '#Vacation',
+ 'priority' => 2,
+ 'redirect' => 'cgp_rule-vacation.html',
+ 'conditions' => [
+ { conditionname => 'Human Generated', },
+ { conditionname => 'From',
+ op => 'not in',
+ params => '#RepliedAddresses',
+ },
+ ( $cgi->param('VacationTill')
+ ? ( { conditionname => 'Current Date',
+ op => 'less than', #is less?
+ params => scalar($cgi->param('VacationTill')),
+ }
+ )
+ : ()
+ ),
+ ],
+ 'actions' => [
+ { action => 'Reply with',
+ params => scalar($cgi->param('VacationText')),
+ },
+ { action => "Remember 'From' in",
+ params => 'RepliedAddresses',
+ },
+ ],
+ )
+%>
diff --git a/httemplate/edit/process/cust_main.cgi b/httemplate/edit/process/cust_main.cgi
index 3158d7b..24cecea 100755
--- a/httemplate/edit/process/cust_main.cgi
+++ b/httemplate/edit/process/cust_main.cgi
@@ -73,6 +73,10 @@ if ( defined($cgi->param('same')) && $cgi->param('same') eq "Y" ) {
);
}
+if ( $cgi->param('no_credit_limit') ) {
+ $new->setfield('credit_limit', '');
+}
+
$new->tagnum( [ $cgi->param('tagnum') ] );
my %usedatetime = ( 'birthdate' => 1 );
@@ -247,6 +251,11 @@ if ( $new->custnum eq '' ) {
$new->payinfo($new_account.'@'.$new_aba);
}
+ if ( ! $conf->exists('cust_main-edit_signupdate') or
+ ! $new->signupdate ) {
+ $new->signupdate($old->signupdate);
+ }
+
warn "$me calling $new -> replace( $old, \ @invoicing_list )" if $DEBUG;
local($FS::cust_main::DEBUG) = $DEBUG if $DEBUG;
local($FS::Record::DEBUG) = $DEBUG if $DEBUG;
diff --git a/httemplate/edit/process/cust_pay.cgi b/httemplate/edit/process/cust_pay.cgi
index df506c6..d6bbf06 100755
--- a/httemplate/edit/process/cust_pay.cgi
+++ b/httemplate/edit/process/cust_pay.cgi
@@ -27,9 +27,6 @@
%}
<%init>
-die "access denied"
- unless $FS::CurrentUser::CurrentUser->access_right('Post payment');
-
$cgi->param('linknum') =~ /^(\d+)$/
or die "Illegal linknum: ". $cgi->param('linknum');
my $linknum = $1;
@@ -47,11 +44,18 @@ my $new = new FS::cust_pay ( {
map {
$_, scalar($cgi->param($_));
} qw( paid payby payinfo paybatch
- pkgnum
+ pkgnum discount_term
)
#} fields('cust_pay')
} );
+my @rights = ('Post payment');
+push @rights, 'Post check payment' if $new->payby eq 'BILL';
+push @rights, 'Post cash payment' if $new->payby eq 'CASH';
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right(\@rights);
+
my $error = $new->insert( 'manual' => 1 );
</%init>
diff --git a/httemplate/edit/process/cust_refund.cgi b/httemplate/edit/process/cust_refund.cgi
index 5749e53..389bc99 100755
--- a/httemplate/edit/process/cust_refund.cgi
+++ b/httemplate/edit/process/cust_refund.cgi
@@ -28,8 +28,21 @@ my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
my $link = $cgi->param('popup') ? 'popup' : '';
+my $payby = $cgi->param('payby');
+
+my @rights = ();
+push @rights, 'Post refund' if $payby /^(BILL|CASH)$/;
+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)$/;
+push @rights, 'Refund credit card payment' if $payby eq 'CARD';
+push @rights, 'Refund Echeck payment' if $payby eq 'CHEK';
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right(\@rights);
+
my $error = '';
-if ( $cgi->param('payby') =~ /^(CARD|CHEK)$/ ) {
+if ( $payby =~ /^(CARD|CHEK)$/ ) {
my %options = ();
my $bop = $FS::payby::payby2bop{$1};
$cgi->param('refund') =~ /^(\d*)(\.\d{2})?$/
diff --git a/httemplate/edit/process/domain_record.cgi b/httemplate/edit/process/domain_record.cgi
index 2e427e4..8369f71 100755
--- a/httemplate/edit/process/domain_record.cgi
+++ b/httemplate/edit/process/domain_record.cgi
@@ -1,8 +1,14 @@
%if ( $error ) {
% errorpage($error);
-%} else {
+%} elsif ( $recnum ) { #editing
+<% header('Nameservice record changed') %>
+ <SCRIPT TYPE="text/javascript">
+ window.top.location.reload();
+ </SCRIPT>
+ </BODY></HTML>
+%} else { #adding
% my $svcnum = $new->svcnum;
-<% $cgi->redirect(popurl(3). "view/svc_domain.cgi?$svcnum") %>
+<% $cgi->redirect(popurl(3). "view/svc_domain.cgi?$svcnum#dns") %>
%}
<%init>
@@ -11,7 +17,7 @@ die "access denied"
my $recnum = $cgi->param('recnum');
-my $old = qsearchs('agent',{'recnum'=>$recnum}) if $recnum;
+my $old = qsearchs('domain_record',{'recnum'=>$recnum}) if $recnum;
my $new = new FS::domain_record ( {
map {
@@ -21,10 +27,11 @@ my $new = new FS::domain_record ( {
my $error;
if ( $recnum ) {
- $error=$new->replace($old);
+ $new->svcnum( $old->svcnum );
+ $error = $new->replace($old);
} else {
- $error=$new->insert;
- $recnum=$new->getfield('recnum');
+ $error = $new->insert;
+ #$recnum = $new->getfield('recnum');
}
</%init>
diff --git a/httemplate/edit/process/part_pkg.cgi b/httemplate/edit/process/part_pkg.cgi
index c0febf8..97ae4e7 100755
--- a/httemplate/edit/process/part_pkg.cgi
+++ b/httemplate/edit/process/part_pkg.cgi
@@ -103,7 +103,7 @@ my $args_callback = sub {
$options{"usage_taxproductnum_$_"} = $value;
}
- foreach ( $cgi->param('report_option') ) {
+ foreach ( grep $_, $cgi->param('report_option') ) {
$error ||= "Illegal optional report class: $_" unless ( $_ =~ /^\d*$/ );
$options{"report_option_$_"} = 1;
}
@@ -160,6 +160,12 @@ my @process_m2m = (
'target_table' => 'tax_class',
'params' => \@tax_overrides,
},
+ { 'link_table' => 'part_pkg_discount',
+ 'target_table' => 'discount',
+ 'params' => [ map $cgi->param($_),
+ grep /^discountnum/, $cgi->param
+ ],
+ },
{ 'link_table' => 'part_pkg_link',
'target_table' => 'part_pkg',
'base_field' => 'src_pkgpart',
diff --git a/httemplate/edit/process/prospect_main.html b/httemplate/edit/process/prospect_main.html
index 34d2642..ca4dfab 100644
--- a/httemplate/edit/process/prospect_main.html
+++ b/httemplate/edit/process/prospect_main.html
@@ -4,7 +4,7 @@
'agent_virt' => 1,
'process_o2m' => {
'table' => 'contact',
- 'fields' => [qw( first last title comment )],
+ 'fields' => \@contact_fields,
},
'redirect' => popurl(3). 'view/prospect_main.html?',
)
@@ -31,4 +31,9 @@ my $args_callback = sub {
};
+my @contact_fields = qw( first last title comment emailaddress );
+foreach my $phone_type ( qsearch({table=>'phone_type', order_by=>'weight'}) ) {
+ push @contact_fields, 'phonetypenum'.$phone_type->phonetypenum;
+}
+
</%init>
diff --git a/httemplate/edit/process/quick-cust_pkg.cgi b/httemplate/edit/process/quick-cust_pkg.cgi
index 2fde17f..599f760 100644
--- a/httemplate/edit/process/quick-cust_pkg.cgi
+++ b/httemplate/edit/process/quick-cust_pkg.cgi
@@ -66,6 +66,10 @@ my $cust_pkg = new FS::cust_pkg {
'discountnum_amount' => scalar($cgi->param('discountnum_amount')),
'discountnum_percent' => scalar($cgi->param('discountnum_percent')),
'discountnum_months' => scalar($cgi->param('discountnum_months')),
+ 'contract_end' => ( scalar($cgi->param('contract_end'))
+ ? parse_datetime($cgi->param('contract_end'))
+ : ''
+ ),
#'discountnum_disabled' => scalar($cgi->param('discountnum_disabled')),
};
@@ -74,7 +78,7 @@ 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 )
+ qw( custnum address1 address2 city county state zip country geocode )
};
$opt{'cust_location'} = $cust_location;
}
diff --git a/httemplate/edit/process/rate_time.cgi b/httemplate/edit/process/rate_time.cgi
index 4fa78ce..2b00be3 100644
--- a/httemplate/edit/process/rate_time.cgi
+++ b/httemplate/edit/process/rate_time.cgi
@@ -4,7 +4,6 @@
% } else {
<% $cgi->redirect(popurl(3). "browse/rate_time.html" ) %>
% }
-%# dumper_html(\%vars, \%old_ints, {$rate_time->intervals}) %>
<%init>
my $error = '';
die "access denied"
@@ -87,7 +86,7 @@ if(!$error) {
sub l2wtime {
my ($d, $h, $m, $a) = @_;
- $h += 24*$d + 12*$a;
+ $h = ($h % 12) + 24*$d + 12*$a;
$m += 60*$h;
return 60*$m
}
diff --git a/httemplate/edit/process/svc_acct.cgi b/httemplate/edit/process/svc_acct.cgi
index 0b272b5..ba21ab4 100755
--- a/httemplate/edit/process/svc_acct.cgi
+++ b/httemplate/edit/process/svc_acct.cgi
@@ -70,7 +70,7 @@ if ( $svcnum ) {
grep { $new->$_ }
qw( seconds upbytes downbytes totalbytes );
- $error ||= "invalid $_" foreach grep { $hash{$_} !~ /^\d+$/ } keys %hash;
+ $error ||= "invalid $_" foreach grep { $hash{$_} !~ /^-?\d+$/ } keys %hash;
$error ||= $new->set_usage(\%hash); #unoverlimit and trigger radius changes
last; #once is enough
}
diff --git a/httemplate/edit/process/svc_domain-defaultrecords.cgi b/httemplate/edit/process/svc_domain-defaultrecords.cgi
new file mode 100644
index 0000000..ec3d221
--- /dev/null
+++ b/httemplate/edit/process/svc_domain-defaultrecords.cgi
@@ -0,0 +1,18 @@
+% if ( $error ) {
+% errorpage($error);
+% } else {
+<% $cgi->redirect(popurl(3). "view/svc_domain.cgi?$svcnum#dns") %>
+% }
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Edit domain nameservice');
+
+my $svcnum = scalar($cgi->param('svcnum'));
+
+my $svc_domain = qsearchs('svc_domain', { 'svcnum' => $svcnum })
+ or die 'unknown svc_domain.svcnum';
+
+my $error = $svc_domain->insert_defaultrecords;
+
+</%init>
diff --git a/httemplate/edit/prospect_main-ocr.html b/httemplate/edit/prospect_main-ocr.html
new file mode 100644
index 0000000..41fc4c1
--- /dev/null
+++ b/httemplate/edit/prospect_main-ocr.html
@@ -0,0 +1,86 @@
+<% include("/elements/header.html", 'Upload business card' ) %>
+
+% if ( $error ) {
+ <FONT SIZE="+1" COLOR="#ff0000">Error: <% $error %></FONT>
+ <BR><BR>
+% } else {
+
+ <FORM ACTION="prospect_main.html" METHOD="POST">
+ <INPUT TYPE="hidden" NAME="session" VALUE="<% $session %>">
+
+ <TABLE>
+
+% my $num = 0;
+% foreach my $line ( @lines ) {
+ <TR>
+ <TD>
+ <INPUT TYPE="hidden" NAME="val<%$num%>" VALUE="<% $line |h %>">
+ <SELECT NAME="sel<%$num%>">
+ <OPTION VALUE="">
+ <OPTION VALUE="name">Name
+ <OPTION VALUE="contactnum0_title">Title
+ <OPTION VALUE="company">Company
+ <OPTION VALUE="contactnum0_emailaddress">Email
+ <OPTION VALUE="address1">Address (1)
+ <OPTION VALUE="address2">Address (2)
+ <OPTION VALUE="city_state_zip">City, State, Zip
+% my @phone_types = qsearch({table=>'phone_type',order_by=>'weight'});
+% foreach my $phone_type ( @phone_types ) {
+% next if $phone_type->typename eq 'Home';
+ <OPTION VALUE="contactnum0_phonetypenum<% $phone_type->phonetypenum %>"><% $phone_type->typename |h %> phone
+% }
+ <OPTION VALUE="contactnum0_comment">Comment
+ </SELECT>
+ </TD>
+ <TD><% $line %></TD>
+
+% unless ( $num++) {
+
+ <TD ROWSPAN="9999"><IMG SRC="<%$p%>view/image.cgi?type=png;prefname=bizcard<%$session%>" WIDTH=604 HEIGHT=328></IMG></TD>
+
+% }
+
+ </TR>
+% }
+
+ </TABLE>
+
+ <BR>
+ <INPUT TYPE="submit" VALUE="Create prospect">
+
+% }
+<% include('/elements/footer.html') %>
+<%init>
+
+my $fh = $cgi->upload('card');
+
+my $error = '';
+my @lines = ();
+my $session = '';
+if ( defined $fh ) {
+
+ local $/;
+ my $logo_data = <$fh>;
+
+ $session = int(rand(4294967296)); #XXX
+ my $pref = new FS::access_user_pref({
+ 'usernum' => $FS::CurrentUser::CurrentUser->usernum,
+ 'prefname' => "bizcard$session",
+ 'prefvalue' => encode_base64($logo_data),
+ 'expiration' => time + 3600, #1h? 1m?
+ });
+ my $pref_error = $pref->insert;
+ if ( $pref_error ) {
+ die "FATAL: couldn't set preview cookie: $pref_error\n";
+ }
+
+ @lines = eval { ocr_image($logo_data); };
+ $error = $@ if $error;
+
+} else {
+
+ $error = 'No file uploaded';
+
+}
+
+</%init>
diff --git a/httemplate/edit/prospect_main-upload.html b/httemplate/edit/prospect_main-upload.html
new file mode 100644
index 0000000..24b1caa
--- /dev/null
+++ b/httemplate/edit/prospect_main-upload.html
@@ -0,0 +1,7 @@
+<% include("/elements/header.html", 'Upload business card' ) %>
+
+ <FORM ACTION="prospect_main-ocr.html" METHOD="POST" ENCTYPE="multipart/form-data">
+ <INPUT TYPE="file" NAME="card">
+ <BR><INPUT TYPE="submit" NAME="submit" VALUE="Upload">
+
+<% include('/elements/footer.html') %>
diff --git a/httemplate/edit/prospect_main.html b/httemplate/edit/prospect_main.html
index e867907..c260eb8 100644
--- a/httemplate/edit/prospect_main.html
+++ b/httemplate/edit/prospect_main.html
@@ -5,6 +5,7 @@
'agentnum' => 'Agent',
'company' => 'Company',
'contactnum' => 'Contact',
+ 'locationnum' => '&nbsp;',
},
'fields' => [
{ 'field' => 'agentnum',
@@ -34,6 +35,7 @@
'empty_label' => 'No address',
},
],
+ 'new_callback' => $new_callback,
'edit_callback' => $edit_callback,
'error_callbacck' => $error_callback,
'agent_virt' => 1,
@@ -62,6 +64,48 @@ if ( $cgi->param('error') ) {
}
+my $new_callback = sub {
+ my( $cgi, $prospect_main, $fields_listref, $opt_hashref ) = @_;
+
+ if ( $cgi->param('session') =~ /^(\w+)$/ ) {
+ my $session = $1;
+
+ #add a link to the image.cgi for this card
+ $opt_hashref->{'html_bottom'} .=
+ qq(<BR><IMG SRC="${p}view/image.cgi?type=png;prefname=bizcard$session" ).
+ ' WIDTH=604 HEIGHT=328><BR>';
+
+ #fill in the incoming params: name, address1/address2, city_state_zip
+ foreach my $param ( grep /^sel\d+$/, $cgi->param ) {
+ $param =~ /^sel(\d+)$/ or die 'again, wtf (daily)';
+ my $num = $1;
+ my $field = $cgi->param($param);
+ my $value = $cgi->param("val$num");
+ $cgi->param($field => $value);
+ }
+
+ if ( $cgi->param('company') ) {
+ $prospect_main->company( $cgi->param('company') );
+ }
+
+ if ( $cgi->param('name') =~ /^(.*\S+)\s+(\w+)\s*$/ ) {
+ $cgi->param('contactnum0_first' => $1);
+ $cgi->param('contactnum0_last' => $2);
+ }
+
+ if ( grep $cgi->param($_), qw( address1 address2 city_state_zip ) ) {
+ $cgi->param('locationnum', -1);
+ if ( $cgi->param('city_state_zip') =~ /^(\s*)([\w\s]+)[\., ]+(\w{2})[, ]+(\d{5}(-\d{4})?)/ ) {
+ $cgi->param('city' => $2);
+ $cgi->param('state' => $3);
+ $cgi->param('zip' => $4);
+ }
+ }
+
+ }
+
+};
+
my $edit_callback = sub {
#my( $cgi, $prospect_main, $fields_listref, $opt_hashref ) = @_;
my( $cgi, $prospect_main ) = @_;
diff --git a/httemplate/edit/rate_time.cgi b/httemplate/edit/rate_time.cgi
index eca8fbb..7ee39ef 100644
--- a/httemplate/edit/rate_time.cgi
+++ b/httemplate/edit/rate_time.cgi
@@ -39,7 +39,7 @@ my $day = [ 0 => 'Sun',
4 => 'Thu',
5 => 'Fri',
6 => 'Sat', ];
-my $hour = [ map( {$_, sprintf('%02d',$_) } 0..11 )];
+my $hour = [ map( {$_, sprintf('%02d',$_) } 12, 1..11 )];
my $min = [ map( {$_, sprintf('%02d',$_) } 0,30 )];
my $ampm = [ 0 => 'AM', 1 => 'PM' ];
@@ -57,7 +57,7 @@ if($ratetimenum) {
else {
foreach my $interval ($rate_time->intervals) {
push @data, [ map { int($_/86400) % 7,
- int($_/3600) % 12,
+ (int($_/3600) % 12 || 12),
int($_/60) % 60,
int($_/43200) % 2, }
( $interval->stime, $interval->etime )
diff --git a/httemplate/edit/svc_acct.cgi b/httemplate/edit/svc_acct.cgi
index 59b5d10..0a191b4 100755
--- a/httemplate/edit/svc_acct.cgi
+++ b/httemplate/edit/svc_acct.cgi
@@ -255,222 +255,12 @@ Service # <% $svcnum ? "<B>$svcnum</B>" : " (NEW)" %><BR>
</TR>
% }
-
-% if ( $communigate
-% && $part_svc->part_svc_column('cgp_type')->columnflag ne 'F' )
-% {
-
-% # settings
-
- <TR>
- <TD ALIGN="right">Mailbox type</TD>
- <TD>
- <SELECT NAME="cgp_type">
-% foreach my $option (qw( MultiMailbox TextMailbox MailDirMailbox
-% AGrade BGrade CGrade )) {
- <OPTION VALUE="<% $option %>"
- <% $option eq $svc_acct->cgp_type() ? 'SELECTED' : '' %>
- ><% $option %>
-% }
- </SELECT>
- </TD>
- </TR>
-
-% } else {
- <INPUT TYPE="hidden" NAME="cgp_type" VALUE="<% $svc_acct->cgp_type() %>">
-% }
-
-
-% #false laziness w/svc_domain
-% if ( $communigate
-% && $part_svc->part_svc_column('cgp_accessmodes')->columnflag ne 'F' )
-% {
-
- <TR>
- <TD ALIGN="right">Enabled services</TD>
- <TD>
- <% include( '/elements/communigate_pro-accessmodes.html',
- 'curr_value' => $svc_acct->cgp_accessmodes,
- )
- %>
- </TD>
- </TR>
-
-% } else {
- <INPUT TYPE="hidden" NAME="cgp_accessmodes" VALUE="<% $svc_acct->cgp_accessmodes() |h %>">
-% }
-
-
-% if ( $part_svc->part_svc_column('quota')->columnflag eq 'F' ) {
- <INPUT TYPE="hidden" NAME="quota" VALUE="<% $svc_acct->quota %>">
-% } else {
-% my $quota_label = $communigate ? 'Mail storage limit' : 'Quota';
- <TR>
- <TD ALIGN="right"><% $quota_label %></TD>
- <TD><INPUT TYPE="text" NAME="quota" VALUE="<% $svc_acct->quota %>"></TD>
- </TR>
-% }
-
-% tie my %cgp_label, 'Tie::IxHash',
-% 'file_quota' => 'File storage limit',
-% 'file_maxnum' => 'Number of files limit',
-% 'file_maxsize' => 'File size limit',
-% ;
-%
-% foreach my $key (keys %cgp_label) {
-%
-% if ( !$communigate || $part_svc->part_svc_column($key)->columnflag eq 'F' ){
- <INPUT TYPE="hidden" NAME="<%$key%>" VALUE="<% $svc_acct->$key() |h %>">
-% } else {
-
- <TR>
- <TD ALIGN="right"><% $cgp_label{$key} %></TD>
- <TD><INPUT TYPE="text" NAME="<% $key %>" VALUE="<% $svc_acct->$key() |h %>"></TD>
- </TR>
-
-% }
-% }
-
-% if ( $communigate ) {
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Password recovery',
- 'field' => 'password_recover',
- 'curr_value' => $svc_acct->password_recover,
- 'value' => 'Y',
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Allowed mail rules',
- 'field' => 'cgp_rulesallowed',
- 'options' => [ '', 'No', 'Filter Only', 'All But Exec', 'Any' ],
- 'labels' => {
- '' => 'default (No)', #No always the default?
- },
- 'curr_value' => $svc_acct->cgp_rulesallowed,
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'RPOP modifications',
- 'field' => 'cgp_rpopallowed',
- 'curr_value' => $svc_acct->cgp_rpopallowed,
- 'value' => 'Y',
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Accepts mail to "all"',
- 'field' => 'cgp_mailtoall',
- 'curr_value' => $svc_acct->cgp_mailtoall,
- 'value' => 'Y',
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Add trailer to sent mail',
- 'field' => 'cgp_addmailtrailer',
- 'curr_value' => $svc_acct->cgp_addmailtrailer,
- 'value' => 'Y',
- )
- %>
-
-% #preferences
-
-%# false laziness w/svc_domain acct_def
- <TR>
- <TD ALIGN="right">Message delete method</TD>
- <TD>
- <SELECT NAME="cgp_deletemode">
-% for ( 'Move To Trash', 'Immediately', 'Mark' ) {
- <OPTION VALUE="<% $_ %>"
- <% $_ eq $svc_acct->cgp_deletemode ? 'SELECTED' : '' %>
- ><% $_ %>
-% }
- </SELECT>
- </TD>
- </TR>
-
- <% include('/elements/tr-select.html',
- 'label' => 'On logout remove trash',
- 'field' => 'cgp_emptytrash',
- 'options' => $svc_acct->cgp_emptytrash_values,
- 'labels' => {
- '' => 'default (92 days)', #right?
- },
- 'curr_value' => $svc_acct->cgp_emptytrash,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Language',
- 'field' => 'cgp_language',
- 'options' => [ '', qw( English Arabic Chinese Dutch French German Hebrew Italian Japanese Portuguese Russian Slovak Spanish Thai ) ],
- 'labels' => {
- '' => 'default (English)',
- },
- 'curr_value' => $svc_acct->cgp_language,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Time zone',
- 'field' => 'cgp_timezone',
- 'options' => $svc_acct->cgp_timezone_values,
- 'labels' => {
- '' => 'default (HostOS)',
- },
- 'curr_value' => $svc_acct->cgp_timezone,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Layout',
- 'field' => 'cgp_skinname',
- 'options' => [ '', '***', 'GoldFleece', 'Skin2' ],
- 'labels' => {
- '' => 'default (***)',
- },
- 'curr_value' => $svc_acct->cgp_skinname,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Pronto style',
- 'field' => 'cgp_prontoskinname',
- 'options' => [ '', 'Pronto', 'Pronto-darkflame', 'Pronto-steel', 'Pronto-twilight', ],
-
- 'curr_value' => $svc_acct->cgp_prontoskinname,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Send read receipts',
- 'field' => 'cgp_sendmdnmode',
- 'options' => [ '', 'Never', 'Manually', 'Automatically' ],
- 'labels' => {
- '' => 'default (Automatically)',
- },
- 'curr_value' => $svc_acct->cgp_language,
- )
- %>
-
-%#XXX vacation message, redirect all mail, mail rules
-
-% } else {
-
-% for (qw( password_recover cgp_rulesallowed cgp_rpopallowed cgp_mailtoall
-% cgp_addmailtrailer
-% cgp_deletemode cgp_emptytrash cgp_language cgp_timezone
-% cgp_skinname cgp_sendmdnmode
-% ) ) {
-
- <INPUT TYPE="hidden" NAME="<% $_ %>" VALUE="<% $svc_acct->$_() %>">
-% }
-
-% }
-
+<% include('svc_acct/communigate.html',
+ 'svc_acct' => $svc_acct,
+ 'part_svc' => $part_svc,
+ 'communigate' => $communigate,
+ )
+%>
% if ( $part_svc->part_svc_column('slipip')->columnflag =~ /^[FA]$/ ) {
<INPUT TYPE="hidden" NAME="slipip" VALUE="<% $svc_acct->slipip %>">
diff --git a/httemplate/edit/svc_acct/communigate.html b/httemplate/edit/svc_acct/communigate.html
new file mode 100644
index 0000000..6370a54
--- /dev/null
+++ b/httemplate/edit/svc_acct/communigate.html
@@ -0,0 +1,249 @@
+% if ( $communigate
+% && $part_svc->part_svc_column('cgp_type')->columnflag ne 'F' )
+% {
+
+% # settings
+
+ <TR>
+ <TD ALIGN="right">Mailbox type</TD>
+ <TD>
+ <SELECT NAME="cgp_type">
+% foreach my $option (qw( MultiMailbox TextMailbox MailDirMailbox
+% AGrade BGrade CGrade )) {
+ <OPTION VALUE="<% $option %>"
+ <% $option eq $svc_acct->cgp_type() ? 'SELECTED' : '' %>
+ ><% $option %>
+% }
+ </SELECT>
+ </TD>
+ </TR>
+
+% } else {
+ <INPUT TYPE="hidden" NAME="cgp_type" VALUE="<% $svc_acct->cgp_type() %>">
+% }
+
+
+% #false laziness w/svc_domain
+% if ( $communigate
+% && $part_svc->part_svc_column('cgp_accessmodes')->columnflag ne 'F' )
+% {
+
+ <TR>
+ <TD ALIGN="right">Enabled services</TD>
+ <TD>
+ <% include( '/elements/communigate_pro-accessmodes.html',
+ 'curr_value' => $svc_acct->cgp_accessmodes,
+ )
+ %>
+ </TD>
+ </TR>
+
+% } else {
+ <INPUT TYPE="hidden" NAME="cgp_accessmodes" VALUE="<% $svc_acct->cgp_accessmodes() |h %>">
+% }
+
+
+% if ( $part_svc->part_svc_column('quota')->columnflag eq 'F' ) {
+ <INPUT TYPE="hidden" NAME="quota" VALUE="<% $svc_acct->quota %>">
+% } else {
+% my $quota_label = $communigate ? 'Mail storage limit' : 'Quota';
+ <TR>
+ <TD ALIGN="right"><% $quota_label %></TD>
+ <TD><INPUT TYPE="text" NAME="quota" VALUE="<% $svc_acct->quota %>"></TD>
+ </TR>
+% }
+
+% tie my %cgp_label, 'Tie::IxHash',
+% 'file_quota' => 'File storage limit',
+% 'file_maxnum' => 'Number of files limit',
+% 'file_maxsize' => 'File size limit',
+% ;
+%
+% foreach my $key (keys %cgp_label) {
+%
+% if ( !$communigate || $part_svc->part_svc_column($key)->columnflag eq 'F' ){
+ <INPUT TYPE="hidden" NAME="<%$key%>" VALUE="<% $svc_acct->$key() |h %>">
+% } else {
+
+ <TR>
+ <TD ALIGN="right"><% $cgp_label{$key} %></TD>
+ <TD><INPUT TYPE="text" NAME="<% $key %>" VALUE="<% $svc_acct->$key() |h %>"></TD>
+ </TR>
+
+% }
+% }
+
+% if ( $communigate ) {
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Password recovery',
+ 'field' => 'password_recover',
+ 'curr_value' => $svc_acct->password_recover,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Allowed mail rules',
+ 'field' => 'cgp_rulesallowed',
+ 'options' => [ '', 'No', 'Filter Only', 'All But Exec', 'Any' ],
+ 'labels' => {
+ '' => 'default (No)', #No always the default?
+ },
+ 'curr_value' => $svc_acct->cgp_rulesallowed,
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'RPOP modifications',
+ 'field' => 'cgp_rpopallowed',
+ 'curr_value' => $svc_acct->cgp_rpopallowed,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Accepts mail to "all"',
+ 'field' => 'cgp_mailtoall',
+ 'curr_value' => $svc_acct->cgp_mailtoall,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Add trailer to sent mail',
+ 'field' => 'cgp_addmailtrailer',
+ 'curr_value' => $svc_acct->cgp_addmailtrailer,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Archive messages after',
+ 'field' => 'cgp_archiveafter',
+ 'options' => [ '', 0, 86400, 172800, 259200, 432000, 604800,
+ 1209600, 2592000, 7776000, 15552000, 31536000,
+ 63072000
+ ],
+ 'labels' => {
+ '' => 'default (730 days)',#730 always default?
+ 0 => 'Never',
+ 86400 => '24 hours',
+ 172800 => '2 days',
+ 259200 => '3 days',
+ 432000 => '5 days',
+ 604800 => '7 days',
+ 1209600 => '2 weeks',
+ 2592000 => '30 days',
+ 7776000 => '90 days',
+ 15552000 => '180 days',
+ 31536000 => '365 days',
+ 63072000 => '730 days',
+ },
+ 'curr_value' => $svc_acct->cgp_archiveafter,
+ )
+ %>
+
+% #preferences
+
+%# false laziness w/svc_domain acct_def
+ <TR>
+ <TD ALIGN="right">Message delete method</TD>
+ <TD>
+ <SELECT NAME="cgp_deletemode">
+% for ( 'Move To Trash', 'Immediately', 'Mark' ) {
+ <OPTION VALUE="<% $_ %>"
+ <% $_ eq $svc_acct->cgp_deletemode ? 'SELECTED' : '' %>
+ ><% $_ %>
+% }
+ </SELECT>
+ </TD>
+ </TR>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'On logout remove trash',
+ 'field' => 'cgp_emptytrash',
+ 'options' => $svc_acct->cgp_emptytrash_values,
+ 'labels' => {
+ '' => 'default (92 days)', #right?
+ },
+ 'curr_value' => $svc_acct->cgp_emptytrash,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Language',
+ 'field' => 'cgp_language',
+ 'options' => [ '', qw( English Arabic Chinese Dutch French German Hebrew Italian Japanese Portuguese Russian Slovak Spanish Thai ) ],
+ 'labels' => {
+ '' => 'default (English)',
+ },
+ 'curr_value' => $svc_acct->cgp_language,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Time zone',
+ 'field' => 'cgp_timezone',
+ 'options' => $svc_acct->cgp_timezone_values,
+ 'labels' => {
+ '' => 'default (HostOS)',
+ },
+ 'curr_value' => $svc_acct->cgp_timezone,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Layout',
+ 'field' => 'cgp_skinname',
+ 'options' => [ '', '***', 'GoldFleece', 'Skin2' ],
+ 'labels' => {
+ '' => 'default (***)',
+ },
+ 'curr_value' => $svc_acct->cgp_skinname,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Pronto style',
+ 'field' => 'cgp_prontoskinname',
+ 'options' => [ '', 'Pronto', 'Pronto-darkflame', 'Pronto-steel', 'Pronto-twilight', ],
+
+ 'curr_value' => $svc_acct->cgp_prontoskinname,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Send read receipts',
+ 'field' => 'cgp_sendmdnmode',
+ 'options' => [ '', 'Never', 'Manually', 'Automatically' ],
+ 'labels' => {
+ '' => 'default (Automatically)',
+ },
+ 'curr_value' => $svc_acct->cgp_language,
+ )
+ %>
+
+% } else {
+
+% for (qw( password_recover cgp_rulesallowed cgp_rpopallowed cgp_mailtoall
+% cgp_addmailtrailer
+% cgp_deletemode cgp_emptytrash cgp_language cgp_timezone
+% cgp_skinname cgp_sendmdnmode
+% ) ) {
+
+ <INPUT TYPE="hidden" NAME="<% $_ %>" VALUE="<% $svc_acct->$_() %>">
+% }
+
+% }
+
+<%init>
+
+my %opt = @_;
+
+my $svc_acct = $opt{'svc_acct'};
+my $part_svc = $opt{'part_svc'};
+
+my $communigate = $opt{'communigate'};
+
+</%init>
diff --git a/httemplate/edit/svc_domain.cgi b/httemplate/edit/svc_domain.cgi
index 5abe9d6..54a933d 100755
--- a/httemplate/edit/svc_domain.cgi
+++ b/httemplate/edit/svc_domain.cgi
@@ -16,6 +16,7 @@
<INPUT TYPE="text" NAME="domain" VALUE="<% $domain %>" SIZE=28 MAXLENGTH=63>
% } else {
<B><% $domain %></B>
+ <INPUT TYPE="hidden" NAME="domain" VALUE="<% $domain %>">
% }
% if ($export) {
@@ -38,269 +39,22 @@ Available top-level domains: <% $export->option('tlds') %>
</TD>
</TR>
-% if ( $communigate ) {
- <TR>
- <TD ALIGN="right">Administrator domain</TD>
- <TD>
- <% include('/elements/select-domain.html',
- 'element_name' => 'parent_svcnum',
- 'curr_value' => $svc_domain->parent_svcnum,
- 'empty_label' => '(none)',
- )
- %>
- </TD>
- </TR>
-% } else {
- <INPUT TYPE="hidden" NAME="parent_svcnum" VALUE="<% $svc_domain->parent_svcnum %>">
-% }
-
-% if ( $communigate
-% && $part_svc->part_svc_column('cgp_aliases')->columnflag !~ /^[FA]$/ ) {
-
- <TR>
- <TD ALIGN="right">Aliases</TD>
- <TD><INPUT TYPE="text" NAME="cgp_aliases" VALUE="<% $svc_domain->cgp_aliases %>"></TD>
- </TR>
-
-% } else {
- <INPUT TYPE="hidden" NAME="cgp_aliases" VALUE="<% $svc_domain->cgp_aliases %>">
-% }
-
-% if ( $part_svc->part_svc_column('max_accounts')->columnflag =~ /^[FA]$/ ) {
- <INPUT TYPE="hidden" NAME="max_accounts" VALUE="<% $svc_domain->max_accounts %>">
-% } else {
- <TR>
- <TD ALIGN="right">Maximum number of accounts</TD>
- <TD>
- <INPUT TYPE="text" NAME="max_accounts" SIZE=5 MAXLENGTH=6 VALUE="<% $svc_domain->max_accounts %>">
- </TD>
- </TR>
-% }
-
-% if ( $communigate
-% && $part_svc->part_svc_column('cgp_accessmodes')->columnflag ne 'F' )
-% {
-
- <TR>
- <TD ALIGN="right">Enabled services</TD>
- <TD>
- <% include( '/elements/communigate_pro-accessmodes.html',
- 'curr_value' => $svc_domain->cgp_accessmodes,
- )
- %>
- </TD>
- </TR>
-
-% } else {
- <INPUT TYPE="hidden" NAME="cgp_accessmodes" VALUE="<% $svc_domain->cgp_accessmodes() |h %>">
-% }
-
-% if ( $communigate
-% && $part_svc->part_svc_column('trailer')->columnflag ne 'F' )
-% {
-
- <TR>
- <TD ALIGN="right">Mail trailer</TD>
- <TD>
- <TEXTAREA NAME="trailer" ROWS=5 COLS=60><% $svc_domain->trailer() |h %></TEXTAREA>
- </TD>
- </TR>
-
-% } else {
- <INPUT TYPE="hidden" NAME="trailer" VALUE="<% $svc_domain->trailer() |h %>">
-% }
-
-
-</TABLE>
-<BR>
-
-% if ( $communigate ) {
-
-Account defaults
-<% ntable("#cccccc",2) %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Password modification',
- 'field' => 'acct_def_password_selfchange',
- 'curr_value' => $svc_domain->acct_def_password_selfchange,
- 'value' => 'Y',
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Password recovery',
- 'field' => 'acct_def_password_recover',
- 'curr_value' => $svc_domain->acct_def_password_recover,
- 'value' => 'Y',
- )
- %>
-
- <TR>
- <TD ALIGN="right">Enabled services
- </TD>
- <TD><% include('/elements/communigate_pro-accessmodes.html',
- 'element_name_prefix' => 'acct_def_cgp_accessmodes_',
- 'curr_value' => $svc_domain->acct_def_cgp_accessmodes,
- )
- %>
- </TD>
- </TR>
-
- <% include('/elements/tr-input-text.html',
- 'label' => 'Mail storage limit',
- 'field' => 'acct_def_quota',
- 'curr_value' => $svc_domain->acct_def_quota,
- )
- %>
- <% include('/elements/tr-input-text.html',
- 'label' => 'File storage limit',
- 'field' => 'acct_def_file_quota',
- 'curr_value' => $svc_domain->acct_def_file_quota,
- )
- %>
- <% include('/elements/tr-input-text.html',
- 'label' => 'Files limit',
- 'field' => 'acct_def_file_maxnum',
- 'curr_value' => $svc_domain->acct_def_file_maxnum,
- )
- %>
- <% include('/elements/tr-input-text.html',
- 'label' => 'File size limit',
- 'field' => 'acct_def_file_maxsize',
- 'curr_value' => $svc_domain->acct_def_file_maxsize,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Allowed mail rules',
- 'field' => 'acct_def_cgp_rulesallowed',
- 'options' => [ '', 'No', 'Filter Only', 'All But Exec', 'Any' ],
- 'labels' => {
- '' => 'default (No)', #No always the default?
- },
- 'curr_value' => $svc_domain->acct_def_cgp_rulesallowed,
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'RPOP modifications',
- 'field' => 'acct_def_cgp_rpopallowed',
- 'curr_value' => $svc_domain->acct_def_cgp_rpopallowed,
- 'value' => 'Y',
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Accepts mail to "all"',
- 'field' => 'acct_def_cgp_mailtoall',
- 'curr_value' => $svc_domain->acct_def_cgp_mailtoall,
- 'value' => 'Y',
- )
- %>
-
- <% include('/elements/tr-checkbox.html',
- 'label' => 'Add trailer to sent mail',
- 'field' => 'acct_def_cgp_addmailtrailer',
- 'curr_value' => $svc_domain->acct_def_cgp_addmailtrailer,
- 'value' => 'Y',
- )
- %>
-
-%# false laziness w/svc_acct acct_def
- <TR>
- <TD ALIGN="right">Message delete method</TD>
- <TD>
- <SELECT NAME="acct_def_cgp_deletemode">
-% for ( 'Move To Trash', 'Immediately', 'Mark' ) {
- <OPTION VALUE="<% $_ %>"
- <% $_ eq $svc_domain->acct_def_cgp_deletemode ? 'SELECTED' : '' %>
- ><% $_ %>
-% }
- </SELECT>
- </TD>
- </TR>
-
- <% include('/elements/tr-select.html',
- 'label' => 'On logout remove trash',
- 'field' => 'acct_def_cgp_emptytrash',
- 'options' => $svc_domain->cgp_emptytrash_values,
- 'labels' => {
- '' => 'default (92 days)', #right?
- },
- 'curr_value' => $svc_domain->acct_def_cgp_emptytrash,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Language',
- 'field' => 'acct_def_cgp_language',
- 'options' => [ '', qw( English Arabic Chinese Dutch French German Hebrew Italian Japanese Portuguese Russian Slovak Spanish Thai ) ],
- 'labels' => {
- '' => 'default (English)',
- },
- 'curr_value' => $svc_domain->acct_def_cgp_language,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Time zone',
- 'field' => 'acct_def_cgp_timezone',
- 'options' => $svc_domain->cgp_timezone_values,
- 'labels' => {
- '' => 'default (HostOS)',
- },
- 'curr_value' => $svc_domain->acct_def_cgp_timezone,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Layout',
- 'field' => 'acct_def_cgp_skinname',
- 'options' => [ '', '***', 'GoldFleece', 'Skin2' ],
- 'labels' => {
- '' => 'default (***)',
- },
- 'curr_value' => $svc_domain->acct_def_cgp_skinname,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Pronto style',
- 'field' => 'acct_def_cgp_prontoskinname',
- 'options' => [ '', 'Pronto', 'Pronto-darkflame', 'Pronto-steel', 'Pronto-twilight', ],
- 'curr_value' => $svc_domain->acct_def_cgp_prontoskinname,
- )
- %>
-
- <% include('/elements/tr-select.html',
- 'label' => 'Send read receipts',
- 'field' => 'acct_def_cgp_sendmdnmode',
- 'options' => [ '', 'Never', 'Manually', 'Automatically' ],
- 'labels' => {
- '' => 'default (Automatically)',
- },
- 'curr_value' => $svc_domain->acct_def_cgp_language,
- )
- %>
-
-% #XXX rules, archive rule, spam foldering rule(s)
+<% include('svc_domain/communigate-basics.html',
+ 'svc_domain' => $svc_domain,
+ 'part_svc' => $part_svc,
+ 'communigate' => $communigate,
+ )
+%>
</TABLE>
<BR>
-% } else {
-
-% foreach my $f (qw( password_selfchange password_recover cgp_accessmodes
-% quota file_quota file_maxnum file_maxsize
-% cgp_rulesallowed cgp_rpopallowed cgp_mailtoall
-% cgp_addmailtrailer
-% cgp_deletemode cgp_emptytrash cgp_language
-% cgp_timezone cgp_skinname cgp_sendmdnmode
-% )) {
- <INPUT TYPE="hidden" NAME="acct_def_<%$f%>" VALUE="<% $svc_domain->get("acct_def_$f") %>">
-% }
-
-% }
+<% include('svc_domain/communigate-acct_defaults.html',
+ 'svc_domain' => $svc_domain,
+ 'part_svc' => $part_svc,
+ 'communigate' => $communigate,
+ )
+%>
<INPUT TYPE="submit" VALUE="Submit">
diff --git a/httemplate/edit/svc_domain/communigate-acct_defaults.html b/httemplate/edit/svc_domain/communigate-acct_defaults.html
new file mode 100644
index 0000000..3426a8e
--- /dev/null
+++ b/httemplate/edit/svc_domain/communigate-acct_defaults.html
@@ -0,0 +1,223 @@
+% if ( $communigate ) {
+
+Account defaults
+<% ntable("#cccccc",2) %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Password modification',
+ 'field' => 'acct_def_password_selfchange',
+ 'curr_value' => $svc_domain->acct_def_password_selfchange,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Password recovery',
+ 'field' => 'acct_def_password_recover',
+ 'curr_value' => $svc_domain->acct_def_password_recover,
+ 'value' => 'Y',
+ )
+ %>
+
+ <TR>
+ <TD ALIGN="right">Enabled services
+ </TD>
+ <TD><% include('/elements/communigate_pro-accessmodes.html',
+ 'element_name_prefix' => 'acct_def_cgp_accessmodes_',
+ 'curr_value' => $svc_domain->acct_def_cgp_accessmodes,
+ )
+ %>
+ </TD>
+ </TR>
+
+ <% include('/elements/tr-input-text.html',
+ 'label' => 'Mail storage limit',
+ 'field' => 'acct_def_quota',
+ 'curr_value' => $svc_domain->acct_def_quota,
+ )
+ %>
+ <% include('/elements/tr-input-text.html',
+ 'label' => 'File storage limit',
+ 'field' => 'acct_def_file_quota',
+ 'curr_value' => $svc_domain->acct_def_file_quota,
+ )
+ %>
+ <% include('/elements/tr-input-text.html',
+ 'label' => 'Files limit',
+ 'field' => 'acct_def_file_maxnum',
+ 'curr_value' => $svc_domain->acct_def_file_maxnum,
+ )
+ %>
+ <% include('/elements/tr-input-text.html',
+ 'label' => 'File size limit',
+ 'field' => 'acct_def_file_maxsize',
+ 'curr_value' => $svc_domain->acct_def_file_maxsize,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Allowed mail rules',
+ 'field' => 'acct_def_cgp_rulesallowed',
+ 'options' => [ '', 'No', 'Filter Only', 'All But Exec', 'Any' ],
+ 'labels' => {
+ '' => 'default (No)', #No always the default?
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_rulesallowed,
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'RPOP modifications',
+ 'field' => 'acct_def_cgp_rpopallowed',
+ 'curr_value' => $svc_domain->acct_def_cgp_rpopallowed,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Accepts mail to "all"',
+ 'field' => 'acct_def_cgp_mailtoall',
+ 'curr_value' => $svc_domain->acct_def_cgp_mailtoall,
+ 'value' => 'Y',
+ )
+ %>
+
+ <% include('/elements/tr-checkbox.html',
+ 'label' => 'Add trailer to sent mail',
+ 'field' => 'acct_def_cgp_addmailtrailer',
+ 'curr_value' => $svc_domain->acct_def_cgp_addmailtrailer,
+ 'value' => 'Y',
+ )
+ %>
+
+%# more false laziness w/svc_acct acct_def
+ <% include('/elements/tr-select.html',
+ 'label' => 'Archive messages after',
+ 'field' => 'acct_def_cgp_archiveafter',
+ 'options' => [ '', 0, 86400, 172800, 259200, 432000, 604800,
+ 1209600, 2592000, 7776000, 15552000, 31536000,
+ 63072000
+ ],
+ 'labels' => {
+ '' => 'default (730 days)',#730 always default?
+ 0 => 'Never',
+ 86400 => '24 hours',
+ 172800 => '2 days',
+ 259200 => '3 days',
+ 432000 => '5 days',
+ 604800 => '7 days',
+ 1209600 => '2 weeks',
+ 2592000 => '30 days',
+ 7776000 => '90 days',
+ 15552000 => '180 days',
+ 31536000 => '365 days',
+ 63072000 => '730 days',
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_archiveafter,
+ )
+ %>
+
+%# false laziness w/svc_acct acct_def
+ <TR>
+ <TD ALIGN="right">Message delete method</TD>
+ <TD>
+ <SELECT NAME="acct_def_cgp_deletemode">
+% for ( 'Move To Trash', 'Immediately', 'Mark' ) {
+ <OPTION VALUE="<% $_ %>"
+ <% $_ eq $svc_domain->acct_def_cgp_deletemode ? 'SELECTED' : '' %>
+ ><% $_ %>
+% }
+ </SELECT>
+ </TD>
+ </TR>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'On logout remove trash',
+ 'field' => 'acct_def_cgp_emptytrash',
+ 'options' => $svc_domain->cgp_emptytrash_values,
+ 'labels' => {
+ '' => 'default (92 days)', #right?
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_emptytrash,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Language',
+ 'field' => 'acct_def_cgp_language',
+ 'options' => [ '', qw( English Arabic Chinese Dutch French German Hebrew Italian Japanese Portuguese Russian Slovak Spanish Thai ) ],
+ 'labels' => {
+ '' => 'default (English)',
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_language,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Time zone',
+ 'field' => 'acct_def_cgp_timezone',
+ 'options' => $svc_domain->cgp_timezone_values,
+ 'labels' => {
+ '' => 'default (HostOS)',
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_timezone,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Layout',
+ 'field' => 'acct_def_cgp_skinname',
+ 'options' => [ '', '***', 'GoldFleece', 'Skin2' ],
+ 'labels' => {
+ '' => 'default (***)',
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_skinname,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Pronto style',
+ 'field' => 'acct_def_cgp_prontoskinname',
+ 'options' => [ '', 'Pronto', 'Pronto-darkflame', 'Pronto-steel', 'Pronto-twilight', ],
+ 'curr_value' => $svc_domain->acct_def_cgp_prontoskinname,
+ )
+ %>
+
+ <% include('/elements/tr-select.html',
+ 'label' => 'Send read receipts',
+ 'field' => 'acct_def_cgp_sendmdnmode',
+ 'options' => [ '', 'Never', 'Manually', 'Automatically' ],
+ 'labels' => {
+ '' => 'default (Automatically)',
+ },
+ 'curr_value' => $svc_domain->acct_def_cgp_language,
+ )
+ %>
+
+</TABLE>
+<BR>
+
+% } else {
+
+% foreach my $f (qw( password_selfchange password_recover cgp_accessmodes
+% quota file_quota file_maxnum file_maxsize
+% cgp_rulesallowed cgp_rpopallowed cgp_mailtoall
+% cgp_addmailtrailer
+% cgp_deletemode cgp_emptytrash cgp_language
+% cgp_timezone cgp_skinname cgp_sendmdnmode
+% )) {
+ <INPUT TYPE="hidden" NAME="acct_def_<%$f%>" VALUE="<% $svc_domain->get("acct_def_$f") %>">
+% }
+
+% }
+
+<%init>
+
+my %opt = @_;
+
+my $svc_domain = $opt{'svc_domain'};
+my $part_svc = $opt{'part_svc'};
+
+my $communigate = $opt{'communigate'};
+
+</%init>
diff --git a/httemplate/edit/svc_domain/communigate-basics.html b/httemplate/edit/svc_domain/communigate-basics.html
new file mode 100644
index 0000000..ff401c0
--- /dev/null
+++ b/httemplate/edit/svc_domain/communigate-basics.html
@@ -0,0 +1,82 @@
+% if ( $communigate ) {
+ <TR>
+ <TD ALIGN="right">Administrator domain</TD>
+ <TD>
+ <% include('/elements/select-domain.html',
+ 'element_name' => 'parent_svcnum',
+ 'curr_value' => $svc_domain->parent_svcnum,
+ 'empty_label' => '(none)',
+ )
+ %>
+ </TD>
+ </TR>
+% } else {
+ <INPUT TYPE="hidden" NAME="parent_svcnum" VALUE="<% $svc_domain->parent_svcnum %>">
+% }
+
+% if ( $communigate
+% && $part_svc->part_svc_column('cgp_aliases')->columnflag !~ /^[FA]$/ ) {
+
+ <TR>
+ <TD ALIGN="right">Aliases</TD>
+ <TD><INPUT TYPE="text" NAME="cgp_aliases" VALUE="<% $svc_domain->cgp_aliases %>"></TD>
+ </TR>
+
+% } else {
+ <INPUT TYPE="hidden" NAME="cgp_aliases" VALUE="<% $svc_domain->cgp_aliases %>">
+% }
+
+% if ( $part_svc->part_svc_column('max_accounts')->columnflag =~ /^[FA]$/ ) {
+ <INPUT TYPE="hidden" NAME="max_accounts" VALUE="<% $svc_domain->max_accounts %>">
+% } else {
+ <TR>
+ <TD ALIGN="right">Maximum number of accounts</TD>
+ <TD>
+ <INPUT TYPE="text" NAME="max_accounts" SIZE=5 MAXLENGTH=6 VALUE="<% $svc_domain->max_accounts %>">
+ </TD>
+ </TR>
+% }
+
+% if ( $communigate
+% && $part_svc->part_svc_column('cgp_accessmodes')->columnflag ne 'F' )
+% {
+
+ <TR>
+ <TD ALIGN="right">Enabled services</TD>
+ <TD>
+ <% include( '/elements/communigate_pro-accessmodes.html',
+ 'curr_value' => $svc_domain->cgp_accessmodes,
+ )
+ %>
+ </TD>
+ </TR>
+
+% } else {
+ <INPUT TYPE="hidden" NAME="cgp_accessmodes" VALUE="<% $svc_domain->cgp_accessmodes() |h %>">
+% }
+
+% if ( $communigate
+% && $part_svc->part_svc_column('trailer')->columnflag ne 'F' )
+% {
+
+ <TR>
+ <TD ALIGN="right">Mail trailer</TD>
+ <TD>
+ <TEXTAREA NAME="trailer" ROWS=5 COLS=60><% $svc_domain->trailer() |h %></TEXTAREA>
+ </TD>
+ </TR>
+
+% } else {
+ <INPUT TYPE="hidden" NAME="trailer" VALUE="<% $svc_domain->trailer() |h %>">
+% }
+
+<%init>
+
+my %opt = @_;
+
+my $svc_domain = $opt{'svc_domain'};
+my $part_svc = $opt{'part_svc'};
+
+my $communigate = $opt{'communigate'};
+
+</%init>
diff --git a/httemplate/elements/city.html b/httemplate/elements/city.html
index 61d0578..956d353 100644
--- a/httemplate/elements/city.html
+++ b/httemplate/elements/city.html
@@ -64,7 +64,7 @@ Example:
<%$pre%>city_select_changed(what.form.<% $pre %>city_select);
what.form.<% $pre %>city.style.display = 'none';
what.form.<% $pre %>city_select.style.display = '';
- } else {
+ } else if ( what.form.<% $pre %>city.style.display == 'none' ) {
// turn on the text city, turn off the select
what.form.<%$ pre %>city.value = saved_<%$pre%>city;
what.form.<% $pre %>city.style.display = '';
diff --git a/httemplate/elements/contact.html b/httemplate/elements/contact.html
index a7a33b1..eea3694 100644
--- a/httemplate/elements/contact.html
+++ b/httemplate/elements/contact.html
@@ -5,12 +5,38 @@
<TABLE>
<TR>
% foreach my $field ( @fields ) {
+%
+% my $value = '';
+% if ( $field =~ /^phonetypenum(\d+)$/ ) {
+% my $contact_phone = qsearchs('contact_phone', {
+% 'contactnum' => $curr_value,
+% 'phonetypenum' => $1,
+% });
+% if ( $contact_phone ) {
+% $value = $contact_phone->phonenum;
+% $value .= 'x'.$contact_phone->extension
+% if $contact_phone->extension;
+% $value = '+'. $contact_phone->countrycode. " $value"
+% if $contact_phone->countrycode
+% && $contact_phone->countrycode ne '1';
+% }
+% } elsif ( $field eq 'emailaddress' ) {
+% #XXX multiple not yet supported
+% my $contact_email = qsearchs('contact_email', {
+% 'contactnum' => $curr_value,
+% });
+% $value = $contact_email->emailaddress if $contact_email;
+% } else {
+% $value = $contact->get($field);
+% }
+
<TD>
- <INPUT TYPE = "text"
- NAME = "<%$name%>_<%$field%>"
- ID = "<%$id%>_<%$field%>"
+ <INPUT TYPE = "text"
+ NAME = "<%$name%>_<%$field%>"
+ ID = "<%$id%>_<%$field%>"
+ SIZE = "<% $size{$field} || 15 %>"
VALUE = "<% scalar($cgi->param($name."_$field"))
- || $contact->get($field) |h %>"
+ || $value |h %>"
<% $onchange %>
><BR>
<FONT SIZE="-1"><% $label{$field} %></FONT>
@@ -45,12 +71,25 @@ if ( $curr_value ) {
$contact = new FS::contact {};
}
+my %size = ( 'title' => 12 );
+
tie my %label, 'Tie::IxHash',
- 'first' => 'First name',
- 'last' => 'Last name',
- 'title' => 'Title/Position',
- 'comment' => 'Comment',
+ 'first' => 'First name',
+ 'last' => 'Last name',
+ 'title' => 'Title/Position',
+ 'emailaddress' => 'Email',
;
+
+my $first = 0;
+foreach my $phone_type ( qsearch({table=>'phone_type', order_by=>'weight'}) ) {
+ next if $phone_type->typename eq 'Home';
+ my $f = 'phonetypenum'.$phone_type->phonetypenum;
+ $label{$f} = $phone_type->typename. ' phone';
+ $size{$f} = $first++ ? 11 : 15;
+}
+
+$label{'comment'} = 'Comment';
+
my @fields = keys %label;
</%init>
diff --git a/httemplate/elements/customer-table.html b/httemplate/elements/customer-table.html
index f00419f..3c3f8b2 100644
--- a/httemplate/elements/customer-table.html
+++ b/httemplate/elements/customer-table.html
@@ -22,6 +22,7 @@ Example:
###
'name_singular' => 'customer', #label
+ 'custnum_update_callback' => 'name_of_js_callback' #passed a rownum
#listrefs
'types' => ['immutable', ''], # immutable or ''/text
@@ -98,6 +99,9 @@ Example:
if ( name.length > 0 ) {
customer.value = name;
customer.setAttribute('magic', 'nosearch');
+% if ( $opt{custnum_update_callback} ) {
+ <% $opt{custnum_update_callback} %>(searchrow, '<% $opt{prefix} %>')
+% }
} else {
customer.value = 'Not found';
customer.style.color = '#ff0000';
@@ -162,6 +166,9 @@ Example:
customer_obj.style.display = '';
customer_select.style.display = 'none';
+% if ( $opt{custnum_update_callback} ) {
+ <% $opt{custnum_update_callback} %>(searchrow, '<% $opt{prefix} %>')
+% }
} else {
@@ -223,6 +230,10 @@ Example:
this.style.display = 'none';
customer_obj.style.display = '';
+% if ( $opt{custnum_update_callback} ) {
+ <% $opt{custnum_update_callback} %>(searchrow, '<% $opt{prefix} %>')
+% }
+
}
}
@@ -314,7 +325,7 @@ Example:
>
% } elsif ($types->[$col] eq 'immutable') {
<% $font %><% $value %><% $font ? '</FONT>' : '' %>
- <INPUT TYPE="hidden" NAME="<% $name %>" VALUE="<% $value %>" >
+ <INPUT TYPE="hidden" ID="<% $name %>" NAME="<% $name %>" VALUE="<% $value %>" >
% } else {
Cannot represent unknown type: <% $types->[$col] %>
% }
diff --git a/httemplate/elements/email-link.html b/httemplate/elements/email-link.html
new file mode 100644
index 0000000..692e5bc
--- /dev/null
+++ b/httemplate/elements/email-link.html
@@ -0,0 +1,16 @@
+% if ( $FS::CurrentUser::CurrentUser->access_right('Bulk send customer notices') ) {
+<A HREF="<%$p%>misc/email-customers.html?table=<%$table%>&<%$query%>"><%$label%></A>
+% }
+<%init>
+my %opt = @_;
+my $table = $opt{'table'};
+my $search_hash = $opt{'search_hash'};
+die "'table' required" if !$table;
+die "'search_hash' required" if !$search_hash;
+
+my $uri = new URI;
+$uri->query_form($search_hash);
+my $query = $uri->query;
+my $label = ($opt{'label'} || 'Email a notice to these customers');
+</%init>
+
diff --git a/httemplate/elements/freeside.css b/httemplate/elements/freeside.css
index dfb56e9..6cb1503 100644
--- a/httemplate/elements/freeside.css
+++ b/httemplate/elements/freeside.css
@@ -16,7 +16,7 @@ a[href]:hover {
color: #7e0079;
}
-textarea, input[type="text"] {
+textarea, input[type="text"], input[type="password"] {
border: 1px solid #666666;
padding: 1px;
-moz-border-radius: 2px;
@@ -24,7 +24,7 @@ textarea, input[type="text"] {
border-radius: 2px;
}
-textarea:hover, input[type="text"]:hover {
+textarea:hover, input[type="text"]:hover, input[type="password"]:hover {
border: 1px solid #7e0079;
padding: 1px;
-moz-border-radius: 2px;
@@ -32,7 +32,7 @@ textarea:hover, input[type="text"]:hover {
border-radius: 2px;
}
-textarea:focus, input[type="text"]:focus {
+textarea:focus, input[type="text"]:focus, input[type="password"]:focus {
background-color: #ffffdd;
border: 1px solid #7e0079;
-moz-border-radius: 2px;
@@ -40,6 +40,16 @@ textarea:focus, input[type="text"]:focus {
border-radius: 2px;
}
+.fsdisabled {
+ background-color: #dddddd;
+ color: #666666;
+ border: 1px solid #999999;
+ padding: 1px;
+ -moz-border-radius: 2px;
+ -webkit-border-radius: 2px;
+ border-radius: 2px;
+}
+
input[type="reset"], input[type="submit"], input[type="button"] {
background-color: #dddddd;
border: 1px solid #666666;
@@ -173,6 +183,43 @@ div.fstabcontainer {
filter: progid:DXImageTransform.Microsoft.Shadow(color='#666666', Direction=135, Strength=2);
}
+.fscontainer {
+ overflow:hidden;
+ display:inline-block;
+}
+.fscontainer {
+ display:block;
+}
+
+.fsbox {
+
+ float:left;
+
+ background-color:#ffffff;
+
+ padding:8px;
+ border-top:1px solid #7e0079;
+ border-left:1px solid #7e0079;
+ border-right:1px solid #7e0079;
+ border-bottom:1px solid #7e0079;
+ -moz-border-radius-bottomleft:8px;
+ -moz-border-radius-bottomright:8px;
+ -webkit-border-radius-bottomleft:8px;
+ -webkit-border-radius-bottomright:8px;
+ border-radius-bottomleft:8px;
+ border-radius-bottomright:8px;
+ -moz-box-shadow: #666666 1px 1px 2px;
+ -webkit-box-shadow: #666666 1px 1px 2px;
+ box-shadow: #666666 1px 1px 2px;
+ filter: progid:DXImageTransform.Microsoft.Shadow(color='#666666', Direction=135, Strength=2);
+}
+
+.fsbox .fsbox-title {
+ /*float:left;*/
+ font-size:150%;
+ font-weight:bold;
+}
+
.background {
background-color:#f8f8f8;
}
diff --git a/httemplate/elements/header.html b/httemplate/elements/header.html
index 90ab0b7..c83529e 100644
--- a/httemplate/elements/header.html
+++ b/httemplate/elements/header.html
@@ -15,7 +15,6 @@ Example:
#old-style
include( '/elements/header.html', 'Title', $menubar, $etc, $head);
-
</%doc>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
%#<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
@@ -43,8 +42,8 @@ Example:
<% $head |n %>
</HEAD>
- <BODY BGCOLOR="#f8f8f8" <% $etc |n %> STYLE="margin-top:0; margin-bottom:0; margin-left:0; margin-right:0">
- <table width="100%" CELLPADDING=0 CELLSPACING=0 STYLE="padding-left:0; padding-right:4">
+ <BODY BGCOLOR="#f8f8f8" <% $etc |n %> STYLE="margin-top:0; margin-bottom:0; margin-left:0px; margin-right:0px">
+ <table width="100%" CELLPADDING=0 CELLSPACING=0 STYLE="padding-left:0px; padding-right:4px">
<tr>
<td BGCOLOR="#ffffff"><IMG BORDER=0 ALT="freeside" HEIGHT="36" SRC="<%$fsurl%>view/REAL_logo.cgi"></td>
<td align=left BGCOLOR="#ffffff"> <!-- valign="top" -->
diff --git a/httemplate/elements/input-date-field.html b/httemplate/elements/input-date-field.html
new file mode 100644
index 0000000..2a9bc1d
--- /dev/null
+++ b/httemplate/elements/input-date-field.html
@@ -0,0 +1,50 @@
+% if(!$noinit) {
+<LINK REL="stylesheet" TYPE="text/css" HREF="<%$fsurl%>elements/calendar-win2k-2.css" TITLE="win2k-2">
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/calendar_stripped.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/calendar-en.js"></SCRIPT>
+<SCRIPT TYPE="text/javascript" SRC="<%$fsurl%>elements/calendar-setup.js"></SCRIPT>
+% }
+
+<INPUT TYPE="text" NAME="<% $name %>" ID="<% $name %>_text" VALUE="<% $value %>">
+<IMG SRC="<%$fsurl%>images/calendar.png" ID="<% $name %>_button" STYLE="cursor: pointer" TITLE="Select date">
+
+<SCRIPT TYPE="text/javascript">
+ Calendar.setup({
+ inputField: "<% $name %>_text",
+ ifFormat: "<% $format %>",
+ button: "<% $name %>_button",
+ align: "BR"
+ });
+</SCRIPT>
+
+<%init>
+
+my($name, $value, $format, $usedatetime, $noinit);
+if ( ref($_[0]) ) {
+ my $opt = shift;
+ $name = $opt->{'name'};
+ $value = $opt->{'value'};
+ $format = $opt->{'format'};
+ $usedatetime = $opt->{'usedatetime'};
+ $noinit = $opt->{'noinit'};
+} else {
+ ($name, $value, $format, $usedatetime) = @_;
+}
+
+my $conf = new FS::Conf;
+
+$format ||= $conf->config('date_format') || '%m/%d/%Y';
+
+if ( $value =~ /\S/ ) {
+ if ( $usedatetime ) {
+ my $dt = DateTime->from_epoch(epoch => $value, time_zone => 'floating');
+ $value = $dt->strftime($format);
+ } elsif ( $value =~ /^\d+$/ ) {
+ $value = time2str($format, $value);
+ }
+} else {
+ $value = '';
+}
+
+</%init>
+
diff --git a/httemplate/elements/menu.html b/httemplate/elements/menu.html
index a5bcdeb..1909d90 100644
--- a/httemplate/elements/menu.html
+++ b/httemplate/elements/menu.html
@@ -185,11 +185,16 @@ foreach my $svcdb ( FS::part_svc->svc_tables() ) {
];
}
- if ( $svcdb eq 'svc_acct' ) {
+ if ( $svcdb eq 'svc_acct' || $svcdb eq 'svc_broadband' ) {
$report_svc{"Advanced $lcsname reports"} =
[ $fsurl."search/report_$svcdb.html", '' ];
}
+ if ( $svcdb eq 'svc_phone' ) {
+ $report_svc{"Avaialble phone numbers (DIDs)"} =
+ [ $fsurl."search/phone_avail.html", '' ];
+ }
+
$report_services{$name} = [ \%report_svc, $longname ];
}
@@ -207,7 +212,9 @@ if ( $curuser->access_right('Financial reports') ) {
$report_packages{'separator2'} = '';
}
$report_packages{'All customer packages'} = [ $fsurl.'search/cust_pkg.cgi?pkgnum', 'List all customer packages', ];
+$report_packages{'Package summary'} = [ $fsurl.'search/cust_pkg_summary.html', 'Show package sales summary', ];
$report_packages{'Suspended customer packages'} = [ $fsurl.'search/cust_pkg.cgi?magic=suspended', 'List suspended packages' ];
+$report_packages{'Suspension summary'} = [ $fsurl.'search/cust_pkg_susp.html', 'Show suspension activity', ];
$report_packages{'Customer packages with unconfigured services'} = [ $fsurl.'search/cust_pkg.cgi?APKG_pkgnum', 'List packages which have provisionable services' ];
$report_packages{'FCC Form 477 packages'} = [ $fsurl.'search/report_477.html', 'Summarize packages by census tract for particular types' ]
if $conf->exists('cust_main-require_censustract');
@@ -257,6 +264,7 @@ $report_payments{'Pending Payments'} = [ $fsurl.'search/cust_pay_pending.html?ma
if $curuser->access_right('View customer pending payments');
$report_payments{'Voided Payments'} = [ $fsurl.'search/report_cust_pay.html?void=1', 'Voided payment report (by type and/or date range)' ]
if $curuser->access_right('View customer pending payments');
+$report_payments{'Unapplied Payments'} = [ $fsurl.'search/report_cust_pay.html?unapplied=1', 'Unapplied payment report (by type and/or date range)' ];
$report_payments{'Payment Batches'} = [ $fsurl.'search/pay_batch.html', 'Payment batches (by status and/or date range)' ]
if $conf->exists('batch-enable') || $conf->config('batch-enable_payby');
$report_payments{'Unapplied Payment Aging'} = [ $fsurl.'search/report_unapplied_cust_pay.html', 'Unapplied payment aging report' ];
@@ -270,7 +278,9 @@ if($curuser->access_right('Financial reports')) {
'Rated Call Sales Report' => [ $fsurl.'graph/report_cust_bill_pkg_detail.html', 'Sales report and graph (by agent, package class, usage class and/or date range)' ],
'Employee Commission Report' => [ $fsurl.'search/report_employee_commission.html', '' ],
'Credit Report' => [ $fsurl.'search/report_cust_credit.html', 'Credit report (by employee and/or date range)' ],
+ 'Unapplied Credits' => [ $fsurl.'search/report_cust_credit.html?unapplied=1', 'Unapplied credit report (by type and/or date range)' ],
'Refund Report' => [ $fsurl.'search/report_cust_refund.html', 'Refund report (by type and/or date range)' ],
+ 'Unapplied Refunds' => [ $fsurl.'search/report_cust_refund.html?unapplied=1', 'Unapplied refund report (by type and/or date range)' ],
'Package Costs Report' => [ $fsurl.'graph/report_cust_pkg_cost.html', 'Package setup and recurring costs graph' ],
);
$report_financial{'A/R Aging'} = [ $fsurl.'search/report_receivables.html', 'Accounts Receivable Aging report' ];
@@ -317,6 +327,7 @@ $report_menu{'SQL Query'} = [ $fsurl.'search/report_sql.html', 'SQL Query' ]
tie my %tools_importing, 'Tie::IxHash',
'Customers' => [ $fsurl.'misc/cust_main-import.cgi', '' ],
+ 'Customer packages' => [ $fsurl.'misc/cust_pkg-import.html', '' ],
'Customer comments from CSV file' => [ $fsurl.'misc/cust_main_note-import.html', '' ],
'One-time charges from CSV file' => [ $fsurl.'misc/cust_main-import_charges.cgi', '' ],
'Payments from CSV file' => [ $fsurl.'misc/cust_pay-import.cgi', '' ],
@@ -358,11 +369,13 @@ $tools_menu{'Process payment batches'} = [ $fsurl.'search/pay_batch.cgi?magic=_d
if ( $conf->exists('batch-enable') || $conf->config('batch-enable_payby') )
&& $curuser->access_right('Process batches');
$tools_menu{'Process invoice batches'} = [ $fsurl.'search/bill_batch.cgi' ]
- if ( $conf->exists('invoice_print_pdf') );
+ if $conf->exists('invoice_print_pdf');
$tools_menu{'Job Queue'} = [ $fsurl.'search/queue.html', 'View pending job queue' ]
if $curuser->access_right('Job queue');
$tools_menu{'Ticketing'} = [ \%tools_ticketing, 'Ticketing tools' ]
if $conf->config('ticket_system');
+$tools_menu{'Business card scan'} = [ $fsurl.'edit/prospect_main-upload.html' ]
+ if $curuser->access_right('New prospect');
$tools_menu{'Time Queue'} = [ $fsurl.'search/report_timeworked.html', 'View pending support time' ]
if $curuser->access_right('Time queue');
$tools_menu{'Attachments'} = [ $fsurl.'browse/cust_attachment.html', 'View customer attachments' ]
@@ -398,7 +411,7 @@ if ( $curuser->access_right('Configuration') ) {
#package grouping sub-menu?
$config_pkg{'Package classes'} = [ $fsurl.'browse/pkg_class.html', 'Package classes define groups of packages, for taxation, ordering convenience and reporting.' ];
- $config_pkg{'Package categories'} = [ $fsurl.'browse/pkg_category.html', 'Package categories define groups of package classes.' ];
+ $config_pkg{'Package categories'} = [ $fsurl.'browse/pkg_category.html', 'Package categories define groups of package classes, for invoice sections.' ];
$config_pkg{'Package report classes'} = [ $fsurl.'browse/part_pkg_report_option.html', 'Package classes define optional groups of packages for reporting only.' ];
#eo package grouping sub-menu
@@ -531,8 +544,8 @@ my $wiki = 'http://www.freeside.biz/mediawiki/index.php';
my $doc_link = $conf->config('support-key')
? "$wiki/Supported:Documentation"
: $curuser->access_right('Configuration')
- ? "$wiki/Freeside:1.9:Documentation"
- : "$wiki/Freeside:1.9:Documentation:User";
+ ? "$wiki/Freeside:2.1:Documentation"
+ : "$wiki/Freeside:2.1:Documentation:User";
eval "use RT;"
if $conf->config('ticket_system') eq 'RT_Internal';
diff --git a/httemplate/elements/popup_link.html b/httemplate/elements/popup_link.html
index 49b624c..fbb6ce3 100644
--- a/httemplate/elements/popup_link.html
+++ b/httemplate/elements/popup_link.html
@@ -11,7 +11,7 @@ Example:
'label' => 'click me', # text of <A> tag
#strongly recommended
- 'actionlabel => 'You clicked', # popup title
+ 'actionlabel' => 'You clicked', # popup title
#opt
'width' => 540,
diff --git a/httemplate/elements/search-cust_main.html b/httemplate/elements/search-cust_main.html
index 317922d..e8c645e 100644
--- a/httemplate/elements/search-cust_main.html
+++ b/httemplate/elements/search-cust_main.html
@@ -11,7 +11,7 @@ Example:
);
</%doc>
-<INPUT TYPE="hidden" NAME="<% $field %>" VALUE="<% $value %>">
+<INPUT TYPE="hidden" NAME="<% $field %>" ID="<% $field %>" VALUE="<% $value %>">
<!-- some false laziness w/ misc/batch-cust_pay.html, though not as bad as i'd thought at first... -->
@@ -60,6 +60,9 @@ Example:
function smart_<% $field %>_search(what) {
+ if ( <% $field %>_search_active )
+ return;
+
var customer = what.value;
if ( customer == 'searching...' || customer == ''
diff --git a/httemplate/elements/select-discount_term.html b/httemplate/elements/select-discount_term.html
new file mode 100644
index 0000000..26d877a
--- /dev/null
+++ b/httemplate/elements/select-discount_term.html
@@ -0,0 +1,32 @@
+% if ( scalar(@discount_term) ) {
+ <SELECT NAME="discount_term">
+ <OPTION VALUE="">1 month
+% foreach my $discount_term (@discount_term) {
+% my $sel = ( $cgi->param('discount_term') == $discount_term ) ? 'SELECTED' : '';
+ <OPTION <% $sel %> VALUE="<% $discount_term %>"><% $discount_term. " months" %>
+% }
+ </SELECT>
+% }
+<%init>
+
+my %opt = @_;
+
+my $cgi = $opt{'cgi'};
+
+my @discount_term;
+if ( $opt{discount_term} ) {
+
+ @discount_term = @{ $opt{discount_term} };
+
+} else {
+
+ my $custnum = $opt{'custnum'};
+
+ my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+ or die "unknown custnum $custnum\n";
+
+ @discount_term = $cust_main->discount_terms;
+
+}
+
+</%init>
diff --git a/httemplate/elements/select-month_year.html b/httemplate/elements/select-month_year.html
index 34476bc..cbf90b6 100644
--- a/httemplate/elements/select-month_year.html
+++ b/httemplate/elements/select-month_year.html
@@ -12,7 +12,7 @@
% if ( $opt{'show_month_abbr'} ) {
% @mon = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
% } else {
-% @mon = ( 1 .. 12 );
+% @mon = ( ( map "0$_", 1 .. 9 ), 10 .. 12 ),
% }
%
% my $date = $opt{'selected_date'} || '';
diff --git a/httemplate/elements/select-state.html b/httemplate/elements/select-state.html
index 9b358e2..2d60fde 100644
--- a/httemplate/elements/select-state.html
+++ b/httemplate/elements/select-state.html
@@ -32,7 +32,7 @@ Example:
% foreach my $state ( keys %states ) {
- <OPTION VALUE="<% $state |h %>"<% $state eq $opt{'state'} ? ' SELECTED' : '' %>><% $states{$state} || '(n/a)' %>
+ <OPTION VALUE="<% $state |h %>"<% $state eq $opt{'state'} ? ' SELECTED' : '' %>><% $states{$state} || '(n/a)' |h %>
% }
diff --git a/httemplate/elements/select-terms.html b/httemplate/elements/select-terms.html
index 52f9fb5..1ca586e 100644
--- a/httemplate/elements/select-terms.html
+++ b/httemplate/elements/select-terms.html
@@ -33,7 +33,7 @@ my $empty_label =
my $empty_value = $opt{'empty_value'} || '';
my @terms = ( 'Payable upon receipt',
- ( map "Net $_", 0, 10, 15, 20, 30, 45, 60 ),
+ ( map "Net $_", 0, 10, 15, 20, 30, 45, 60, 90 ),
);
my @pre_options = $opt{pre_options} ? @{ $opt{pre_options} } : ();
diff --git a/httemplate/elements/select-user.html b/httemplate/elements/select-user.html
index bdb92e7..ec2341b 100644
--- a/httemplate/elements/select-user.html
+++ b/httemplate/elements/select-user.html
@@ -4,8 +4,11 @@
<OPTION VALUE="">all</OPTION>
% }
-% foreach my $otaker ( @{ $opt{'otakers'} } ) {
- <OPTION VALUE="<% shift(@{$opt{'usernums'}}) %>"><% $otaker %></OPTION>
+% foreach my $usernum (
+% sort { $opt{'access_user'}->{$a} cmp $opt{'access_user'}->{$b} }
+% keys %{ $opt{'access_user'} }
+% ) {
+ <OPTION VALUE="<%$usernum%>"><% $opt{'access_user'}->{$usernum} %></OPTION>
% }
</SELECT>
@@ -14,15 +17,15 @@
my %opt = @_;
-unless ( $opt{'otakers'} ) {
+unless ( $opt{'access_user'} ) {
- my $sth = dbh->prepare("SELECT username,usernum FROM access_user".
- " WHERE disabled = '' or disabled IS NULL")
- or die dbh->errstr;
+ my $sth = dbh->prepare("
+ SELECT usernum, username FROM access_user
+ WHERE disabled = '' or disabled IS NULL
+ ") or die dbh->errstr;
$sth->execute or die $sth->errstr;
- for($sth->fetchall_arrayref) {
- $opt{'otakers'} = [ map { $_->[0] } @$_ ];
- $opt{'usernums'} = [ map { $_->[1] } @$_ ];
+ while ( my $row = $sth->fetchrow_arrayref ) {
+ $opt{'access_user'}->{$row->[0]} = $row->[1];
}
}
diff --git a/httemplate/elements/standardize_locations.html b/httemplate/elements/standardize_locations.html
new file mode 100644
index 0000000..9f8b71c
--- /dev/null
+++ b/httemplate/elements/standardize_locations.html
@@ -0,0 +1,18 @@
+<% include('/elements/init_overlib.html') %>
+
+<% include( '/elements/xmlhttp.html',
+ 'url' => $p.'misc/xmlhttp-cust_main-address_standardize.html',
+ 'subs' => [ 'address_standardize' ],
+ #'method' => 'POST', #could get too long?
+ )
+%>
+
+<SCRIPT TYPE="text/javascript">
+ <% include('/elements/standardize_locations.js', %options) %>
+</SCRIPT>
+
+<%init>
+
+my (%options) = @_;
+
+</%init>
diff --git a/httemplate/elements/standardize_locations.js b/httemplate/elements/standardize_locations.js
new file mode 100644
index 0000000..e6a4aa6
--- /dev/null
+++ b/httemplate/elements/standardize_locations.js
@@ -0,0 +1,278 @@
+function standardize_locations() {
+
+ var cf = document.<% $formname %>;
+
+ var state_el = cf.elements['<% $main_prefix %>state'];
+ var ship_state_el = cf.elements['<% $ship_prefix %>state'];
+
+ var address_info = new Array(
+% if ( $onlyship ) {
+ 'onlyship', 1,
+% } else {
+% if ( $withfirm ) {
+ 'company', cf.elements['<% $main_prefix %>company'].value,
+% }
+ 'address1', cf.elements['<% $main_prefix %>address1'].value,
+ 'address2', cf.elements['<% $main_prefix %>address2'].value,
+ 'city', cf.elements['<% $main_prefix %>city'].value,
+ 'state', state_el.options[ state_el.selectedIndex ].value,
+ 'zip', cf.elements['<% $main_prefix %>zip'].value,
+% }
+% if ( $withfirm ) {
+ 'ship_company', cf.elements['<% $ship_prefix %>company'].value,
+% }
+ 'ship_address1', cf.elements['<% $ship_prefix %>address1'].value,
+ 'ship_address2', cf.elements['<% $ship_prefix %>address2'].value,
+ 'ship_city', cf.elements['<% $ship_prefix %>city'].value,
+ 'ship_state', ship_state_el.options[ ship_state_el.selectedIndex ].value,
+ 'ship_zip', cf.elements['<% $ship_prefix %>zip'].value
+ );
+
+ address_standardize( address_info, update_address );
+
+}
+
+var standardize_address;
+
+function update_address(arg) {
+
+ var argsHash = eval('(' + arg + ')');
+
+ var changed = argsHash['address_standardized'];
+ var ship_changed = argsHash['ship_address_standardized'];
+ var error = argsHash['error'];
+ var ship_error = argsHash['ship_error'];
+
+
+ //yay closures
+ standardize_address = function () {
+
+ var cf = document.<% $formname %>;
+ var state_el = cf.elements['<% $main_prefix %>state'];
+ var ship_state_el = cf.elements['<% $ship_prefix %>state'];
+
+% if ( !$onlyship ) {
+ if ( changed ) {
+% if ( $withfirm ) {
+ cf.elements['<% $main_prefix %>company'].value = argsHash['new_company'];
+% }
+ cf.elements['<% $main_prefix %>address1'].value = argsHash['new_address1'];
+ cf.elements['<% $main_prefix %>address2'].value = argsHash['new_address2'];
+ cf.elements['<% $main_prefix %>city'].value = argsHash['new_city'];
+ setselect(cf.elements['<% $main_prefix %>state'], argsHash['new_state']);
+ cf.elements['<% $main_prefix %>zip'].value = argsHash['new_zip'];
+ }
+% }
+
+ if ( ship_changed ) {
+% if ( $withfirm ) {
+ cf.elements['<% $ship_prefix %>company'].value = argsHash['new_ship_company'];
+% }
+ cf.elements['<% $ship_prefix %>address1'].value = argsHash['new_ship_address1'];
+ cf.elements['<% $ship_prefix %>address2'].value = argsHash['new_ship_address2'];
+ cf.elements['<% $ship_prefix %>city'].value = argsHash['new_ship_city'];
+ setselect(cf.elements['<% $ship_prefix %>state'], argsHash['new_ship_state']);
+ cf.elements['<% $ship_prefix %>zip'].value = argsHash['new_ship_zip'];
+ }
+
+ post_standardization();
+
+ }
+
+
+
+ if ( changed || ship_changed ) {
+
+% if ( $conf->exists('cust_main-auto_standardize_address') ) {
+
+ standardize_address();
+
+% } else {
+
+ // popup a confirmation popup
+
+ var confirm_change =
+ '<CENTER><BR><B>Confirm address standardization</B><BR><BR>' +
+ '<TABLE>';
+
+ if ( changed ) {
+
+ confirm_change = confirm_change +
+ '<TR><TH>Entered billing address</TH>' +
+ '<TH>Standardized billing address</TH></TR>';
+ // + '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
+
+ if ( argsHash['company'] || argsHash['new_company'] ) {
+ confirm_change = confirm_change +
+ '<TR><TD>' + argsHash['company'] +
+ '</TD><TD>' + argsHash['new_company'] + '</TD></TR>';
+ }
+
+ confirm_change = confirm_change +
+ '<TR><TD>' + argsHash['address1'] +
+ '</TD><TD>' + argsHash['new_address1'] + '</TD></TR>' +
+ '<TR><TD>' + argsHash['address2'] +
+ '</TD><TD>' + argsHash['new_address2'] + '</TD></TR>' +
+ '<TR><TD>' + argsHash['city'] + ', ' + argsHash['state'] + ' ' + argsHash['zip'] +
+ '</TD><TD>' + argsHash['new_city'] + ', ' + argsHash['new_state'] + ' ' + argsHash['new_zip'] + '</TD></TR>' +
+ '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
+
+ }
+
+ if ( ship_changed ) {
+
+ confirm_change = confirm_change +
+ '<TR><TH>Entered service address</TH>' +
+ '<TH>Standardized service address</TH></TR>';
+ // + '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
+
+ if ( argsHash['ship_company'] || argsHash['new_ship_company'] ) {
+ confirm_change = confirm_change +
+ '<TR><TD>' + argsHash['ship_company'] +
+ '</TD><TD>' + argsHash['new_ship_company'] + '</TD></TR>';
+ }
+
+ confirm_change = confirm_change +
+ '<TR><TD>' + argsHash['ship_address1'] +
+ '</TD><TD>' + argsHash['new_ship_address1'] + '</TD></TR>' +
+ '<TR><TD>' + argsHash['ship_address2'] +
+ '</TD><TD>' + argsHash['new_ship_address2'] + '</TD></TR>' +
+ '<TR><TD>' + argsHash['ship_city'] + ', ' + argsHash['ship_state'] + ' ' + argsHash['ship_zip'] +
+ '</TD><TD>' + argsHash['new_ship_city'] + ', ' + argsHash['new_ship_state'] + ' ' + argsHash['new_ship_zip'] + '</TD></TR>' +
+ '<TR><TD>&nbsp;</TD><TD>&nbsp;</TD></TR>';
+
+ }
+
+ var addresses = 'address';
+ var height = 268;
+ if ( changed && ship_changed ) {
+ addresses = 'addresses';
+ height = 396; // #what
+ }
+
+ confirm_change = confirm_change +
+ '<TR><TD>' +
+ '<BUTTON TYPE="button" onClick="post_standardization();"><IMG SRC="<%$p%>images/error.png" ALT=""> Use entered ' + addresses + '</BUTTON>' +
+ '</TD><TD>' +
+ '<BUTTON TYPE="button" onClick="standardize_address();"><IMG SRC="<%$p%>images/tick.png" ALT=""> Use standardized ' + addresses + '</BUTTON>' +
+ '</TD></TR>' +
+ '<TR><TD COLSPAN=2 ALIGN="center">' +
+ '<BUTTON TYPE="button" onClick="document.<% $formname %>.submitButton.disabled=false; parent.cClick();"><IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission</BUTTON></TD></TR>' +
+
+ '</TABLE></CENTER>';
+
+ overlib( confirm_change, CAPTION, 'Confirm address standardization', STICKY, AUTOSTATUSCAP, CLOSETEXT, '', MIDX, 0, MIDY, 0, DRAGGABLE, WIDTH, 576, HEIGHT, height, BGCOLOR, '#333399', CGCOLOR, '#333399', TEXTSIZE, 3 );
+
+% }
+
+ } else {
+
+ post_standardization();
+
+ }
+
+
+}
+
+function post_standardization() {
+
+ var cf = document.<% $formname %>;
+
+% if ( $conf->exists('enable_taxproducts') ) {
+
+ if ( new String(cf.elements['<% $taxpre %>zip'].value).length < 10 )
+ {
+
+ var country_el = cf.elements['<% $taxpre %>country'];
+ var country = country_el.options[ country_el.selectedIndex ].value;
+ var geocode = cf.elements['geocode'].value;
+
+ if ( country == 'CA' || country == 'US' ) {
+
+ var state_el = cf.elements['<% $taxpre %>state'];
+ var state = state_el.options[ state_el.selectedIndex ].value;
+
+ var url = "<% $p %>/misc/choose_tax_location.html" +
+ "?data_vendor=cch-zip" +
+ ";city=" + cf.elements['<% $taxpre %>city'].value +
+ ";state=" + state +
+ ";zip=" + cf.elements['<% $taxpre %>zip'].value +
+ ";country=" + country +
+ ";geocode=" + geocode +
+ ";formname=" + '<% $formname %>' +
+ ";";
+
+ // popup a chooser
+ OLgetAJAX( url, update_geocode, 300 );
+
+ } else {
+
+ cf.elements['geocode'].value = 'DEFAULT';
+ <% $post_geocode %>;
+
+ }
+
+ } else {
+
+ cf.elements['geocode'].value = '';
+ <% $post_geocode %>;
+
+ }
+
+% } else {
+
+ <% $post_geocode %>;
+
+% }
+
+}
+
+function update_geocode() {
+
+ //yay closures
+ set_geocode = function (what) {
+
+ var cf = document.<% $formname %>;
+
+ //alert(what.options[what.selectedIndex].value);
+ var argsHash = eval('(' + what.options[what.selectedIndex].value + ')');
+ cf.elements['<% $taxpre %>city'].value = argsHash['city'];
+ setselect(cf.elements['<% $taxpre %>state'], argsHash['state']);
+ cf.elements['<% $taxpre %>zip'].value = argsHash['zip'];
+ cf.elements['geocode'].value = argsHash['geocode'];
+ <% $post_geocode %>;
+
+ }
+
+ // popup a chooser
+
+ overlib( OLresponseAJAX, CAPTION, 'Select tax location', STICKY, AUTOSTATUSCAP, CLOSETEXT, '', MIDX, 0, MIDY, 0, DRAGGABLE, WIDTH, 576, HEIGHT, 268, BGCOLOR, '#333399', CGCOLOR, '#333399', TEXTSIZE, 3 );
+
+}
+
+function setselect(el, value) {
+
+ for ( var s = 0; s < el.options.length; s++ ) {
+ if ( el.options[s].value == value ) {
+ el.selectedIndex = s;
+ }
+ }
+
+}
+<%init>
+
+my %opt = @_;
+my $conf = new FS::Conf;
+
+my $withfirm = 1;
+
+my $formname = $opt{form} || 'CustomerForm';
+my $onlyship = $opt{onlyship} || '';
+my $main_prefix = $opt{main_prefix} || '';
+my $ship_prefix = $opt{ship_prefix} || ($onlyship ? '' : 'ship_');
+my $taxpre = $main_prefix;
+$taxpre = $ship_prefix if ( $conf->exists('tax-ship_address') || $onlyship );
+my $post_geocode = $opt{callback} || 'post_geocode();';
+$withfirm = 0 if $opt{no_company};
+
+</%init>
diff --git a/httemplate/elements/table-grid.html b/httemplate/elements/table-grid.html
index e1e6c36..4d7deea 100644
--- a/httemplate/elements/table-grid.html
+++ b/httemplate/elements/table-grid.html
@@ -1,8 +1,7 @@
<STYLE TYPE="text/css">
-.grid table { border: solid; empty-cells: show }
-.grid TH { padding-left: 3px; padding-right: 3px; border: 1px solid #dddddd; border-bottom: dashed 1px black; border-right: none }
-.grid TD { padding-left: 3px; padding-right: 3px; empty-cells: show; border: 1px solid #cccccc; border-bottom: none; border-right: none }
+.grid TH { padding-left: 3px; padding-right: 3px; padding-bottom: 2px; border: none; empty-cells: show }
+.grid TD { padding-left: 3px; padding-right: 3px; padding-bottom: 2px; border: none; empty-cells: show }
.inv table { border: none }
.inv TH { border: none }
@@ -10,7 +9,7 @@
</STYLE>
-<TABLE CLASS="grid" CELLSPACING=<% $opt{cellspacing} %> CELLPADDING=<% $opt{cellpadding} %> BORDER=1 BORDERCOLOR="#000000" <% $opt{bgcolor} %> STYLE="border: solid 1px black; empty-cells: show">
+<TABLE CLASS="grid" CELLSPACING=<% $opt{cellspacing} %> CELLPADDING=<% $opt{cellpadding} %> <% $opt{bgcolor} %> STYLE="border: 1px solid #cccccc;">
<%init>
diff --git a/httemplate/elements/tr-pkg_svc.html b/httemplate/elements/tr-pkg_svc.html
index e66bdf7..e68ed4a 100644
--- a/httemplate/elements/tr-pkg_svc.html
+++ b/httemplate/elements/tr-pkg_svc.html
@@ -84,7 +84,7 @@ my @part_svc = qsearch('part_svc', {}, '', $where);
#my $q_part_pkg = $clone_part_pkg || $part_pkg;
#my %pkg_svc = map { $_->svcpart => $_ } $q_part_pkg->pkg_svc;
-my %pkg_svc = map { $_->svcpart => $_ } $part_pkg->pkg_svc;
+my %pkg_svc = map { $_->svcpart => $_ } $part_pkg->pkg_svc('disable_linked'=>1);
my @fixups = ();
my $count = 0;
diff --git a/httemplate/elements/tr-select-cust_tag.html b/httemplate/elements/tr-select-cust_tag.html
index d88f3a8..b2b6d96 100644
--- a/httemplate/elements/tr-select-cust_tag.html
+++ b/httemplate/elements/tr-select-cust_tag.html
@@ -1,4 +1,4 @@
-% if ( $curuser->access_right('Edit customer tags') && @part_tag ) {
+% if ( ($curuser->access_right('Edit customer tags') && @part_tag) || $is_report ) {
<TR>
<TD ALIGN="right"><% $opt{'label'} || 'Tags' %></TD>
@@ -25,6 +25,7 @@ my $curuser = $FS::CurrentUser::CurrentUser;
my %opt = @_;
my $cgi = $opt{'cgi'};
+my $is_report = $opt{'is_report'};
my @curr_tagnum = ();
if ( $cgi->param('error') ) {
diff --git a/httemplate/elements/tr-select-discount_term.html b/httemplate/elements/tr-select-discount_term.html
new file mode 100644
index 0000000..5858267
--- /dev/null
+++ b/httemplate/elements/tr-select-discount_term.html
@@ -0,0 +1,25 @@
+% if ( scalar(@discount_term) ) {
+ <TR>
+ <TD ALIGN="right">Prepayment for</TD>
+ <TD COLSPAN=2>
+ <% include('select-discount_term.html',
+ 'discount_term' => \@discount_term,
+ 'cgi' => $opt{'cgi'},
+ )
+ %>
+ </TD>
+ </TR>
+
+% }
+
+<%init>
+my %opt = @_;
+
+my $custnum = $opt{'custnum'};
+
+my $cust_main = qsearchs('cust_main', { 'custnum' => $custnum } )
+ or die "unknown custnum $custnum\n";
+
+my @discount_term = $cust_main->discount_terms;
+
+</%init>
diff --git a/httemplate/elements/xmlhttp.html b/httemplate/elements/xmlhttp.html
index 2df3c42..ac6f991 100644
--- a/httemplate/elements/xmlhttp.html
+++ b/httemplate/elements/xmlhttp.html
@@ -58,7 +58,11 @@ Example:
return;
if (xmlhttp.status != 200) {
- alert(xmlhttp.status + " status connecting to " + url);
+ if ( xmlhttp.status != 0 ) {
+ //not warning on the 0 errors, they pop up when navagating away
+ // from the page
+ alert(xmlhttp.status + " status connecting to " + url);
+ }
} else {
var data = xmlhttp.responseText;
//alert('received response: ' + data);
diff --git a/httemplate/graph/money_time.cgi b/httemplate/graph/money_time.cgi
index 4e4157e..cde71be 100644
--- a/httemplate/graph/money_time.cgi
+++ b/httemplate/graph/money_time.cgi
@@ -88,7 +88,7 @@ my %link = (
'netsales' => "${p}search/cust_bill.html?agentnum=$agentnum;net=1;",
'credits' => "${p}search/cust_credit.html?agentnum=$agentnum;",
'netcredits' => "${p}search/cust_credit_bill.html?agentnum=$agentnum;",
- 'payments' => "${p}search/cust_pay.cgi?magic=_date;agentnum=$agentnum;",
+ 'payments' => "${p}search/cust_pay.html?magic=_date;agentnum=$agentnum;",
'receipts' => "${p}search/cust_bill_pay.html?agentnum=$agentnum;",
'refunds' => "${p}search/cust_refund.html?magic=_date;agentnum=$agentnum;",
'netrefunds' => "${p}search/cust_credit_refund.html?agentnum=$agentnum;",
diff --git a/httemplate/misc/batch-cust_pay.html b/httemplate/misc/batch-cust_pay.html
index 505f2d0..610f6e1 100644
--- a/httemplate/misc/batch-cust_pay.html
+++ b/httemplate/misc/batch-cust_pay.html
@@ -13,23 +13,63 @@ function warnUnload() {
}
}
window.onbeforeunload = warnUnload;
+
+function select_discount_term(row, prefix) {
+ var custnum_obj = document.getElementById('custnum'+prefix+row);
+ var select_obj = document.getElementById('discount_term'+prefix+row);
+
+ var value = '';
+ if (select_obj.type == 'hidden') {
+ value = select_obj.value;
+ }
+
+ var term_select = document.createElement('SELECT');
+ term_select.setAttribute('name', 'discount_term'+row);
+ term_select.setAttribute('id', 'discount_term'+row);
+ term_select.setAttribute('rownum', row);
+ term_select.style.display = '';
+ select_obj.parentNode.replaceChild(term_select, select_obj);
+ opt(term_select, '', '1 month');
+
+ function select_discount_term_update(discount_terms) {
+
+ var termArray = eval('(' + discount_terms + ')');
+ for ( var t = 0; t < termArray.length; t++ ) {
+ opt(term_select, termArray[t][0], termArray[t][1]);
+ if (termArray[t][0] == value) {
+ term_select.selectedIndex = t+1;
+ }
+ }
+
+ }
+
+ discount_terms(custnum_obj.value, select_discount_term_update);
+
+}
</SCRIPT>
+<% include('/elements/xmlhttp.html',
+ 'url' => $p. 'misc/xmlhttp-cust_main-discount_terms.cgi',
+ 'subs' => [qw( discount_terms )],
+ )
+%>
+
<FORM ACTION="process/batch-cust_pay.cgi" NAME="OneTrueForm" METHOD="POST" onsubmit="document.OneTrueForm.submit.disabled=true;window.onbeforeunload = null;">
<!-- <B>Batch</B> <INPUT TYPE="text" NAME="paybatch"><BR><BR> -->
<% include( "/elements/customer-table.html",
name_singular => 'payment',
- header => [ '', 'Amount', 'Check #', '' ],
- fields => [ sub {'$'}, 'paid', 'payinfo', 'error', ],
- types => [ 'immutable', '', '', 'immutable', ],
- align => [ 'c', 'r', 'r', 'l' ],
- sizes => [ 0, 8, 10, 0, ],
- colors => [ '', '', '', '#ff0000' ],
- param => { () },
- footer => [ '$', '_TOTAL', '', '' ],
- footer_align => [ 'c', 'r', 'r', '' ],
+ header => \@header,
+ fields => \@fields,
+ types => \@types,
+ align => \@align,
+ sizes => \@sizes,
+ colors => \@colors,
+ param => \%param,
+ footer => \@footer,
+ footer_align => \@footer_align,
+ custnum_update_callback => $custnum_update_callback,
)
%>
@@ -41,6 +81,14 @@ window.onbeforeunload = warnUnload;
</FORM>
+%if ( $cgi->param('error') ) {
+<SCRIPT TYPE="text/javascript">
+% for ( my $row = 0; defined($cgi->param("custnum$row")); $row++ ) {
+ select_discount_term(<% $row %>, '');
+% }
+</SCRIPT>
+%}
+
<% include('/elements/footer.html') %>
<%init>
@@ -48,4 +96,36 @@ window.onbeforeunload = warnUnload;
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Post payment batch');
+my @header = ( '', 'Amount', 'Check #' );
+my @fields = ( sub {'$'}, 'paid', 'payinfo' );
+my @types = ( 'immutable', '', '' );
+my @align = ( 'c', 'r', 'r' );
+my @sizes = ( 0, 8, 10 );
+my @colors = ( '', '', '' );
+my %param = ();
+my @footer = ( '$', '_TOTAL', '' );
+my @footer_align = ( 'c', 'r', 'r' );
+my $custnum_update_callback = '';
+
+if ( FS::Record->scalar_sql('SELECT count(*) FROM part_pkg_discount') ) {
+ push @header, '';
+ push @fields, 'discount_term';
+ push @types, 'immutable';
+ push @align, 'r';
+ push @sizes, '0';
+ push @colors, '';
+ push @footer, '';
+ push @footer_align, '';
+ $custnum_update_callback = 'select_discount_term';
+}
+
+push @header, '';
+push @fields, 'error';
+push @types, 'immutable';
+push @align, 'l';
+push @sizes, '0';
+push @colors, '#ff0000';
+push @footer, '';
+push @footer_align, '';
+
</%init>
diff --git a/httemplate/misc/cancel_cust.html b/httemplate/misc/cancel_cust.html
index 12c37eb..b7ecccd 100644
--- a/httemplate/misc/cancel_cust.html
+++ b/httemplate/misc/cancel_cust.html
@@ -2,18 +2,46 @@
<% include('/elements/error.html') %>
+
<FORM NAME="cust_cancel_popup" ACTION="<% popurl(1) %>cust_main-cancel.cgi" METHOD=POST>
<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
<P ALIGN="center"><B>Permanently delete all services and cancel this customer?</B>
- <% $ban %>
-
-<BR><BR>
-
-<% ntable("#cccccc", 2) %>
+<TABLE BORDER="0" CELLSPACING="2"
+STYLE="margin-left:auto; margin-right:auto">
+<TR>
+ <TD ALIGN="right">
+ <INPUT TYPE="radio" NAME="now_or_later" VALUE="0" onclick="toggle(false)" CHECKED />
+ </TD>
+ <TD ALIGN="left">Cancel now</TD>
+</TR>
+<TR>
+ <TD ALIGN="right">
+ <INPUT TYPE="radio" NAME="now_or_later" VALUE="1" onclick="toggle(true)" />
+ </TD>
+ <TD ALIGN="left">Cancel on date:&nbsp;
+ <% include('/elements/input-date-field.html', {
+ 'name' => 'expire',
+ 'value' => time,
+ } ) %>
+ </TD>
+</TR>
+</TABLE>
+<SCRIPT type="text/javascript">
+function toggle(val) {
+ document.getElementById("expire_text").disabled = !val;
+ document.getElementById("ban").disabled = val;
+ document.getElementById("expire_button").style.visibility =
+ val ? 'visible' : 'hidden';
+}
+toggle(false);
+</SCRIPT>
+<% $ban %>
+<TABLE BGCOLOR="#cccccc", BORDER="0" CELLSPACING="2"
+STYLE="margin-left:auto; margin-right:auto">
<% include('/elements/tr-select-reason.html',
'field' => 'reasonnum',
'reason_class' => 'C',
@@ -50,8 +78,8 @@ die "No customer # $custnum" unless $cust_main;
my $ban = '';
if ( $cust_main->payby =~ /^(CARD|DCRD|CHEK|DCHK)$/ ) {
- $ban = '<BR><P ALIGN="center">'.
- '<INPUT TYPE="checkbox" NAME="ban" VALUE="1"> Ban this customer\'s ';
+ $ban = '<P ALIGN="center">'.
+ '<INPUT TYPE="checkbox" NAME="ban" ID="ban" VALUE="1"> Ban this customer\'s ';
if ( $cust_main->payby =~ /^(CARD|DCRD)$/ ) {
$ban .= 'credit card';
} elsif ( $cust_main->payby =~ /^(CHEK|DCHK)$/ ) {
diff --git a/httemplate/misc/change_pkg.cgi b/httemplate/misc/change_pkg.cgi
index 16b7071..ec10b85 100755
--- a/httemplate/misc/change_pkg.cgi
+++ b/httemplate/misc/change_pkg.cgi
@@ -2,7 +2,7 @@
<% include('/elements/error.html') %>
-<FORM ACTION="<% $p %>edit/process/change-cust_pkg.html" METHOD=POST>
+<FORM NAME="OrderPkgForm" ACTION="<% $p %>edit/process/change-cust_pkg.html" METHOD=POST>
<INPUT TYPE="hidden" NAME="pkgnum" VALUE="<% $pkgnum %>">
<% ntable('#cccccc') %>
@@ -31,8 +31,16 @@
</TABLE>
+<% include( '/elements/standardize_locations.html',
+ 'form' => "OrderPkgForm",
+ 'onlyship' => 1,
+ 'no_company' => 1,
+ 'callback' => 'document.OrderPkgForm.submit();',
+ )
+%>
+
<BR>
-<INPUT TYPE="submit" VALUE="Change package">
+<INPUT NAME="submitButton" TYPE="button" VALUE="Change package" onClick="this.disabled=true; standardize_locations();">
</FORM>
</BODY>
diff --git a/httemplate/misc/choose_tax_location.html b/httemplate/misc/choose_tax_location.html
new file mode 100644
index 0000000..dce04c7
--- /dev/null
+++ b/httemplate/misc/choose_tax_location.html
@@ -0,0 +1,90 @@
+<FORM NAME="choosegeocodeform">
+<CENTER><BR><B>Choose tax location</B><BR><BR>
+<P>the geocode is:<% $header %></P>
+<P STYLE="<% $style %>"><% $header %></P>
+
+<SELECT NAME='geocodes' ID='geocodes' STYLE="<% $style %>">
+% foreach my $location (@cust_tax_location) {
+% my %value = ( zip => $zip5,
+% map { $_ => $location->$_ }
+% qw ( city state geocode )
+% );
+% map { $value{$_} = $location{$_} } qw ( city state )
+% if $location{country} eq 'CA';
+%
+% my $value = encode_entities(objToJson({ %value })
+% );
+% my $content = '';
+% $content .= $location->$_. '&nbsp;' x ( $max{$_} - length($location->$_) )
+% foreach qw( city county state );
+% $content .= $location->cityflag eq 'I' ? 'Y' : 'N' ;
+% my $selected = '' ;
+% if ($geocode && $location->geocode eq $geocode) {
+% $selected = 'SELECTED';
+% }
+ <OPTION VALUE="<% $value %>" STYLE="<% $style %>" <% $selected %>><% $content %>
+% }
+</SELECT><BR><BR>
+
+<TABLE><TR>
+ <TD> <BUTTON TYPE="button" onClick="set_geocode(document.getElementById('geocodes'));"><IMG SRC="<%$p%>images/tick.png" ALT=""> Set location </BUTTON></TD>
+ <TD><BUTTON TYPE="button" onClick="document.<% $formname %>.submitButton.disabled=false; parent.cClick();"><IMG SRC="<%$p%>images/cross.png" ALT=""> Cancel submission </BUTTON></TD>
+</TR>
+</TABLE>
+
+</CENTER>
+</FORM>
+<%init>
+
+my $conf = new FS::Conf;
+
+my %location = ();
+
+($location{data_vendor}) = $cgi->param('data_vendor') =~ /^([-\w]+)$/;
+($location{city}) = $cgi->param('city') =~ /^([\w ]+)$/;
+($location{state}) = $cgi->param('state') =~ /^(\w+)$/;
+($location{zip}) = $cgi->param('zip') =~ /^([-\w ]+)$/;
+($location{country}) = $cgi->param('country') =~ /^([\w ]+)$/;
+
+my($geocode) = $cgi->param('geocode') =~ /^([\w]+)$/;
+
+my($formname) = $cgi->param('formname') =~ /^([\w]*)$/;
+$formname ||= 'CustomerForm';
+
+my($zip5, $zip4) = split('-', $location{zip});
+
+#only support US & CA
+my $hashref = { 'data_vendor' => $location{data_vendor} };
+$hashref->{zip} = $location{country} eq 'CA' ? substr($zip5,0,1) : $zip5,
+
+my @keys = keys(%$hashref);
+my @cust_tax_location = ();
+until ( @cust_tax_location ) {
+ @cust_tax_location = qsearch({ table => 'cust_tax_location',
+ hashref => $hashref,
+ order_by => 'LIMIT 50',
+ });
+ last unless scalar(@keys);
+ delete $hashref->{ shift @keys };
+}
+
+my %max = ( city => 4, county => 6, state => 5);
+foreach my $location (@cust_tax_location) {
+ foreach ( qw( city county state ) ) {
+ my $length = length($location->$_);
+ $max{$_} = ($length > $max{$_}) ? $length : $max{$_};
+ }
+}
+foreach ( qw( city county state ) ) {
+ $max{$_} = $location{$_} if $location{$_} > $max{$_};
+ $max{$_}++;
+}
+
+my $header = '&nbsp;&nbsp;';
+$header .= $_. '&nbsp;' x ( $max{lc($_)} - length($_) )
+ foreach qw( City County State );
+$header .= "In city?";
+
+my $style = "font-family:monospace;";
+
+</%init>
diff --git a/httemplate/misc/cust_main-cancel.cgi b/httemplate/misc/cust_main-cancel.cgi
index 009a7d4..44be20c 100755
--- a/httemplate/misc/cust_main-cancel.cgi
+++ b/httemplate/misc/cust_main-cancel.cgi
@@ -1,4 +1,4 @@
-<% header("Customer cancelled") %>
+<% include('/elements/header.html', "Customer cancelled") %>
<SCRIPT TYPE="text/javascript">
window.top.location.reload();
</SCRIPT>
@@ -11,9 +11,11 @@ die "access denied"
my $custnum;
my $ban = '';
+my $expire = '';
if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
$custnum = $1;
$ban = $cgi->param('ban');
+ $expire = $cgi->param('expire');
} else {
my($query) = $cgi->keywords;
$query =~ /^(\d+)$/ || die "Illegal custnum";
@@ -42,11 +44,28 @@ my $cust_main = qsearchs( {
'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
} );
-warn "cancelling $cust_main";
-my @errors = $cust_main->cancel(
- 'ban' => $ban,
- 'reason' => $reasonnum,
-);
+my @errors;
+if($cgi->param('now_or_later')) {
+ $expire = parse_datetime($expire);
+ if($expire) {
+ #warn "setting expire dates on custnum#$custnum\n";
+ my @pkgs = $cust_main->ncancelled_pkgs;
+ @errors = grep {$_} map { $_->cancel(
+ 'reason' => $reasonnum,
+ 'date' => $expire,
+ ) } @pkgs;
+ }
+ else {
+ @errors = ("error parsing expire date: ".$cgi->param('expire'));
+ }
+}
+else {
+ warn "cancelling $cust_main";
+ @errors = $cust_main->cancel(
+ 'ban' => $ban,
+ 'reason' => $reasonnum,
+ );
+}
my $error = join(' / ', @errors) if scalar(@errors);
if ( $error ) {
diff --git a/httemplate/misc/cust_main-import.cgi b/httemplate/misc/cust_main-import.cgi
index 9c1f984..edf4665 100644
--- a/httemplate/misc/cust_main-import.cgi
+++ b/httemplate/misc/cust_main-import.cgi
@@ -30,7 +30,9 @@ Import a file containing customer records.
<SELECT NAME="format">
<!-- <OPTION VALUE="simple">Simple -->
<OPTION VALUE="extended" SELECTED>Extended
+ <OPTION VALUE="extended-plus_options">Extended + options
<OPTION VALUE="extended-plus_company">Extended plus company
+ <OPTION VALUE="extended-plus_company_and_options">Extended plus company and options
<OPTION VALUE="svc_external">External service
<OPTION VALUE="svc_external_svc_phone">External service and phone service
</SELECT>
@@ -89,7 +91,11 @@ Uploaded files can be CSV (comma-separated value) files or Excel spreadsheets.
<b>Extended</b> format has the following field order: <i>agent_custid, refnum<%$req%>, last<%$req%>, first<%$req%>, address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country, daytime, night, ship_last, ship_first, ship_address1, ship_address2, ship_city, ship_state, ship_zip, ship_country, payinfo, paycvv, paydate, invoicing_list, pkgpart, username, _password</i>
<BR><BR>
+<b>Extended plus options</b> format has the following field order: <i>agent_custid, refnum<%$req%>, last<%$req%>, first<%$req%>, address1<%$req%>, address2, city<%$req%>, state<%$req%>, zip<%$req%>, country, daytime, night, ship_last, ship_first, ship_address1, ship_address2, ship_city, ship_state, ship_zip, ship_country, payinfo, paycvv, paydate, invoicing_list, pkgpart, username, _password, options</i>
+
<b>Extended plus company</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, payinfo, paycvv, paydate, invoicing_list, pkgpart, username, _password</i>
+
+<b>Extended plus company and options </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, payinfo, paycvv, paydate, invoicing_list, pkgpart, username, _password, options</i>
<BR><BR>
<b>External service</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, payinfo, paycvv, paydate, invoicing_list, pkgpart, next_bill_date, id, title</i>
@@ -111,7 +117,7 @@ Field information:
of an integer, the string is searched for and if necessary auto-created in the
advertising source table.
- <li><i>payinfo</i>: Credit card number, or leave this, <i>paycvv</i> and <i>paydate</i> blank for email/paper invoicing.
+ <li><i>payinfo</i>: Credit card number, or leave this, <i>paycvv</i> and <i>paydate</i> blank for email/paper invoicing. You may optionally prepend an 'A' or 'D' to the credit card number for automatic or on demand of customer billing respectively
<li><i>paycvv</i>: CVV2 number (three digits on the back of the credit card)
@@ -119,7 +125,7 @@ advertising source table.
<li><i>invoicing_list</i>: Email address for invoices, or POST for postal invoices.
- <li><i>pkgpart</i>: Package definition. Configuration -&gt; Provisioning, services and packages -&gt; View/Edit package definitions
+ <li><i>pkgpart</i>: Package definition. Configuration -&gt; Packages -&gt; Package definitions
<li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified. (Extended and Extended plus company formats)
@@ -127,6 +133,13 @@ advertising source table.
<li><i>title</i>: External service identifier, text
+ <li><i>options</i>: text containing one or more of
+
+ <ul>
+ <li>taxexempt: this customer does not pay taxes
+ <li>postalinvoice: ensure this customer receives a postal invoice
+ </ul>
+
</ul>
<BR>
diff --git a/httemplate/misc/cust_main-import_charges.cgi b/httemplate/misc/cust_main-import_charges.cgi
index 3801929..c844e0e 100644
--- a/httemplate/misc/cust_main-import_charges.cgi
+++ b/httemplate/misc/cust_main-import_charges.cgi
@@ -1,22 +1,69 @@
-<% include('/elements/header.html', 'Batch Customer Charge') %>
+<% include("/elements/header.html",'Batch Payment Charge') %>
+
+Import a CSV file containing customer payments.
+<BR><BR>
<FORM ACTION="process/cust_main-import_charges.cgi" METHOD="post" ENCTYPE="multipart/form-data">
-Import a CSV file containing customer charges.<BR><BR>
-Default file format is CSV, with the following field order: <i>custnum, amount, description</i><BR><BR>
-If <i>amount</i> is negative, a credit will be applied instead.<BR><BR>
-<BR><BR>
+<% &ntable("#cccccc", 2) %>
+
+<% include('/elements/tr-select-agent.html',
+ #'curr_value' => '', #$agentnum,
+ 'label' => "<B>Agent</B>",
+ 'empty_label' => 'Select agent',
+ )
+%>
+
+<TR>
+ <TH ALIGN="right">Format</TH>
+ <TD>
+ <SELECT NAME="format">
+ <OPTION VALUE="simple">Simple
+<!-- <OPTION VALUE="extended" SELECTED>Extended -->
+ </SELECT>
+ </TD>
+</TR>
+
+<TR>
+ <TH ALIGN="right">CSV filename</TH>
+ <TD><INPUT TYPE="file" NAME="csvfile"></TD>
+</TR>
-CSV Filename: <INPUT TYPE="file" NAME="csvfile"><BR><BR>
-<INPUT TYPE="submit" VALUE="Import">
+<TR><TD COLSPAN=2 ALIGN="center" STYLE="padding-top:6px"><INPUT TYPE="submit" VALUE="Import CSV file"></TD></TR>
+
+</TABLE>
</FORM>
-<% include('/elements/footer.html') %>
+<BR>
+
+Simple file format is CSV, with the following field order: <i>custnum, agent_custid, amount, description</i>
+<BR><BR>
+
+<!-- Extended file format is not yet defined</i>
+<BR><BR> -->
+Field information:
+
+<ul>
+
+ <li><i>custnum</i>: This is the freeside customer number. It may be left blank. If specified, agent_custid must be blank.
+
+ <li><i>agent_custid</i>: This is the reseller's idea of the customer number or identifier. It may be left blank. If specified, custnum must be blank.
+
+ <li><i>amount</i>: A numeric value with at most two digits after the decimal point. If <i>amount</i> is negative, a credit will be applied instead.
+
+ <li><i>description</i>: Text describing the transaction.
+
+</ul>
+
+<BR>
+
+<% include('/elements/footer.html') %>
<%init>
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Import');
</%init>
+
diff --git a/httemplate/misc/cust_main-merge.html b/httemplate/misc/cust_main-merge.html
new file mode 100755
index 0000000..4decbef
--- /dev/null
+++ b/httemplate/misc/cust_main-merge.html
@@ -0,0 +1,40 @@
+% if ( $error ) {
+% $cgi->param('error', $error);
+<% $cgi->redirect(popurl(1). "merge_cust.html?". $cgi->query_string ) %>
+% } else {
+<% include('/elements/header-popup.html', "Customer merged") %>
+ <SCRIPT TYPE="text/javascript">
+ window.top.location.href = '<% $p %>view/cust_main.cgi?<% $new_custnum %>';
+%# parent.nd(1) ?
+ </SCRIPT>
+ </BODY>
+</HTML>
+% }
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Merge customer');
+
+my $error = '';
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die "illegal custnum";
+my $custnum = $1;
+
+my $new_custnum;
+if ( $cgi->param('new_custnum') =~ /^(\d+)$/ ) {
+ $new_custnum = $1;
+
+ my $cust_main = qsearchs( {
+ 'table' => 'cust_main',
+ 'hashref' => { 'custnum' => $custnum },
+ 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+ } );
+ die "No customer # $custnum" unless $cust_main;
+
+ $error = $cust_main->merge($new_custnum);
+
+} else {
+ $error = 'Select a customer to merge into';
+}
+
+</%init>
diff --git a/httemplate/misc/cust_main_note-import.cgi b/httemplate/misc/cust_main_note-import.cgi
index b93c5c1..8a94ae4 100644
--- a/httemplate/misc/cust_main_note-import.cgi
+++ b/httemplate/misc/cust_main_note-import.cgi
@@ -108,6 +108,7 @@
% my $fh = $cgi->upload('csvfile');
% my $csv = new Text::CSV_XS;
% my $skip_fuzzies = $cgi->param('fuzzies') ? 0 : 1;
+% my $use_agent_custid = $cgi->param('use_agent_custid') ? 1 : 0;
%
% if ( defined($fh) ) {
<TABLE BGCOLOR="#cccccc" BORDER=0 CELLSPACING=0>
@@ -118,7 +119,7 @@
<TH>First</TH>
<TH>Note to be added</TH>
</TR>
-% my $agentnum => scalar($cgi->param('agentnum')),
+% my $agentnum = scalar($cgi->param('agentnum'));
% my $line;
% my $row = 0;
% while ( defined($line=<$fh>) ) {
@@ -138,7 +139,10 @@
% next unless ( $last || $first || $note );
% my @cust_main = ();
% warn "searching for: $last, $first" if ($first || $last);
-% if ($custnum) {
+% if ($agentnum && $custnum && $use_agent_custid) {
+% @cust_main = qsearch('cust_main', { 'agent' => $agentnum,
+% 'agent_custid' => $custnum } );
+% } elsif ($custnum) { # && !use_agent_custid
% @cust_main = qsearch('cust_main', { 'custnum' => $custnum });
% } else {
% @cust_main = FS::cust_main::smart_search(
diff --git a/httemplate/misc/cust_main_note-import.html b/httemplate/misc/cust_main_note-import.html
index d8fefa7..cc1645d 100644
--- a/httemplate/misc/cust_main_note-import.html
+++ b/httemplate/misc/cust_main_note-import.html
@@ -13,6 +13,13 @@ Anything after the character sequence #! is ignored.
<% &ntable("#cccccc") %>
+<% include('/elements/tr-select-agent.html',
+ #'curr_value' => '', #$agentnum,
+ 'label' => "<B>Agent</B>",
+ 'empty_label' => 'Select agent',
+ )
+%>
+
<TR>
<TH ALIGN="right">CSV filename</TH>
<TD><INPUT TYPE="file" NAME="csvfile"></TD>
@@ -22,6 +29,11 @@ Anything after the character sequence #! is ignored.
<TD><INPUT TYPE="checkbox" NAME="fuzzies"></TD>
</TR>
+<TR>
+ <TH ALIGN="right">custnum is reseller's customer number</TH>
+ <TD><INPUT TYPE="checkbox" NAME="use_agent_custid"></TD>
+</TR>
+
</TABLE>
<BR><BR>
diff --git a/httemplate/misc/cust_pkg-import.html b/httemplate/misc/cust_pkg-import.html
new file mode 100644
index 0000000..b29884d
--- /dev/null
+++ b/httemplate/misc/cust_pkg-import.html
@@ -0,0 +1,150 @@
+<% include("/elements/header.html",'Batch Package Import') %>
+
+Import a file containing package records.
+<BR><BR>
+
+<% include( '/elements/form-file_upload.html',
+ 'name' => 'PackageImportForm',
+ 'action' => 'process/cust_pkg-import.html',
+ 'num_files' => 1,
+ 'fields' => [ 'agentnum', 'pkgbatch', 'format' ],
+ 'message' => 'Package import successful',
+ 'url' => $p."search/cust_pkg.cgi?pkgbatch=$pkgbatch",
+ )
+%>
+
+<% &ntable("#cccccc", 2) %>
+
+ <% include( '/elements/tr-select-agent.html',
+ #'curr_value' => '', #$agentnum,
+ 'label' => "<B>Agent</B>",
+ 'empty_label' => 'Select agent',
+ )
+ %>
+
+ <INPUT TYPE="hidden" NAME="pkgbatch" VALUE="<% $pkgbatch %>"%>
+
+ <TR>
+ <TH ALIGN="right">Format</TH>
+ <TD>
+ <SELECT NAME="format">
+ <OPTION VALUE="default" SELECTED>Default
+ <OPTION VALUE="default-agent_custid">Default with agent_custid
+ <OPTION VALUE="svc_acct">Account service
+ <OPTION VALUE="svc_acct-agent_custid">Account service with agent_custid
+ <OPTION VALUE="svc_phone">Phone service
+ <OPTION VALUE="svc_phone-agent_custid">Phone service with agent_custid
+ <OPTION VALUE="svc_external">External service
+ <OPTION VALUE="svc_external-agent_custid">External service with agent_custid
+ </SELECT>
+ </TD>
+ </TR>
+
+ <% include( '/elements/file-upload.html',
+ 'field' => 'file',
+ 'label' => 'Filename',
+ )
+ %>
+
+ <TR>
+ <TD COLSPAN=2 ALIGN="center" STYLE="padding-top:6px">
+ <INPUT TYPE = "submit"
+ ID = "submit"
+ VALUE = "Import file"
+ onClick = "document.PackageImportForm.submit.disabled=true;"
+ >
+ </TD>
+ </TR>
+
+</TABLE>
+
+</FORM>
+
+<BR>
+Uploaded files can be CSV (comma-separated value) files or Excel spreadsheets. The file should have a .CSV or .XLS extension.
+<BR><BR>
+
+<b>Default</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire</i>
+<BR><BR>
+
+<b>Default with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire</i>
+<BR><BR>
+
+<b>Account service</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, username, _password, domsvc</i>
+<BR><BR>
+
+<b>Account service with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, username, _password, domsvc</i>
+<BR><BR>
+
+<b>Phone sevice</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, countrycode, phonenum, sip_password, pin</i>
+<BR><BR>
+
+<b>Phone service with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, countrycode, phonenum, sip_password, pin</i>
+<BR><BR>
+
+<b>External sevice</b> format has the following field order: <i>custnum<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, id, title</i>
+<BR><BR>
+
+<b>External service with agent_custid</b> format has the following field order: <i>agent_custid<%$req%>, pkgpart<%$req%>, discountnum, start_date, setup, bill, last_bill, susp, adjourn, cancel, expire, id, title</i>
+<BR><BR>
+
+<%$req%> Required fields
+<BR><BR>
+
+Field information:
+
+<ul>
+
+ <li><i>custnum</i>: This specifies an existing customer by custnum.
+
+ <li><i>agent_custid</i>: This specifies an existing customer record by agent_custid.
+
+ <li><i>pkgpart</i>: Package definition. Configuration -&gt; Packages -&gt; Package definitions
+
+ <li><i>discountnum</i>: Optional discount. Configuration -&gt; Packages -&gt; Discounts
+
+ <li><i>start_date</i>: Indicates a future start date; do not fill in for active packages
+
+ <li><i>setup</i>: Indicates setup fee has been charged and package setup on this date
+
+ <li><i>bill</i>: Next bill date
+
+ <li><i>last_bill</i>: Last bill date
+
+ <li><i>susp</i>: Indicates the package is suspended (on the given date).
+
+ <li><i>adjourn</i>: Indicates a future suspension on this date.
+
+ <li><i>cancel</i>: Indicates the package is cancelled (on the given date).
+
+ <li><i>expire</i>: Indicates a future cancellation on this date.
+
+<!--
+ <li><i>username</i> and <i>_password</i> are required if <i>pkgpart</i> is specified. (Extended and Extended plus company formats)
+-->
+
+ <li><i>domsvc</i>: Domain svcnum
+
+ <li><i>id</i>: External service id, integer
+
+ <li><i>title</i>: External service identifier, text
+
+</ul>
+
+<BR>
+
+<% include('/elements/footer.html') %>
+
+<%once>
+
+my $req = qq!<font color="#ff0000">*</font>!;
+
+</%once>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+my $pkgbatch = time2str('webimport-%Y/%m/%d-%T'. "-$$-". rand() * 2**32, time);
+
+</%init>
diff --git a/httemplate/misc/custom_link_proxy.cgi b/httemplate/misc/custom_link_proxy.cgi
new file mode 100644
index 0000000..e5934e4
--- /dev/null
+++ b/httemplate/misc/custom_link_proxy.cgi
@@ -0,0 +1,24 @@
+% if( $response->is_success ) {
+<% $response->decoded_content %>
+% }
+% else {
+<% $response->error_as_HTML %>
+% }
+<%init>
+
+my( $custnum ) = $cgi->param('custnum');
+my $cust_main = qsearchs('cust_main', { custnum => $custnum } )
+ or die "custnum '$custnum' not found"; # just check for existence
+
+my $conf = new FS::Conf;
+my $url = $conf->config('cust_main-custom_link') . $cust_main->custnum;
+#warn $url;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied"
+ unless $curuser->access_right('View customer');
+
+my $ua = new LWP::UserAgent;
+my $response = $ua->get($url);
+</%init>
diff --git a/httemplate/misc/delete-domain_record.cgi b/httemplate/misc/delete-domain_record.cgi
index 08eedde..200365d 100755
--- a/httemplate/misc/delete-domain_record.cgi
+++ b/httemplate/misc/delete-domain_record.cgi
@@ -1,7 +1,7 @@
% if ( $error ) {
% errorpage($error);
% } else {
-<% $cgi->redirect($p. "view/svc_domain.cgi?". $domain_record->svcnum) %>
+<% $cgi->redirect($p. "view/svc_domain.cgi?". $domain_record->svcnum. '#dns') %>
% }
<%init>
diff --git a/httemplate/misc/email-customers.html b/httemplate/misc/email-customers.html
index 201aed4..759c8bf 100644
--- a/httemplate/misc/email-customers.html
+++ b/httemplate/misc/email-customers.html
@@ -1,69 +1,78 @@
<% include('/elements/header.html', $title) %>
<FORM NAME="OneTrueForm" ACTION="email-customers.html" METHOD="POST">
-% foreach my $key ( keys %search ) {
-% my @values = ref($search{$key}) ? @{$search{$key}} : ( $search{$key} );
-% foreach my $value ( @values ) {
- <INPUT TYPE="hidden" NAME="<% $key %>" VALUE="<% $value %>">
-% }
-% }
+<INPUT TYPE="hidden" NAME="table" VALUE="<% $table %>">
+%# Mixing search params with from address, subject, etc. required special-case
+%# handling of those, risked name conflicts, and caused massive problems with
+%# multi-valued search params. We are no longer in search context, so we
+%# pack the search into a Storable string for later use.
+<INPUT TYPE="hidden" NAME="search" VALUE="<% encode_base64(nfreeze(\%search)) %>">
-% if ( $cgi->param('magic') eq 'send' ) {
+% if ( $cgi->param('action') eq 'send' ) {
<FONT SIZE="+2">Sending notice</FONT>
<% include('/elements/progress-init.html',
'OneTrueForm',
- [ keys(%search), qw( from subject html_body text_body ) ],
+ [ qw( search table from subject html_body text_body msgnum ) ],
'process/email-customers.html',
{ 'message' => "Notice sent" }, #would be nice to show #, but..
)
%>
-% } elsif ( $cgi->param('magic') eq 'preview' ) {
+% } elsif ( $cgi->param('action') eq 'preview' ) {
<FONT SIZE="+2">Preview notice</FONT>
% }
-% if ( $cgi->param('magic') ) {
+% if ( $cgi->param('action') ) {
<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+ <INPUT TYPE="hidden" NAME="msgnum" VALUE="<% $cgi->param('msgnum') %>">
+
+% if ( $msg_template ) {
+ <% include('/elements/tr-fixed.html',
+ 'label' => 'Template:',
+ 'value' => $msg_template->msgname,
+ )
+ %>
+% }
<% include('/elements/tr-fixed.html',
'field' => 'from',
'label' => 'From:',
- 'value' => scalar( $cgi->param('from') ),
+ 'value' => scalar( $from ),
)
%>
<% include('/elements/tr-fixed.html',
'field' => 'subject',
'label' => 'Subject:',
- 'value' => scalar( $cgi->param('subject') ),
+ 'value' => scalar( $subject ),
)
%>
- <INPUT TYPE="hidden" NAME="html_body" VALUE="<% $cgi->param('html_body') |h %>">
+ <INPUT TYPE="hidden" NAME="html_body" VALUE="<% $html_body |h %>">
<TR>
<TD ALIGN="right" VALIGN="top">Message (HTML display): </TD>
- <TD CLASS="background" ALIGN="left"><% $cgi->param('html_body') %></TD>
+ <TD CLASS="background" ALIGN="left"><% $html_body %></TD>
</TR>
% my $text_body = HTML::FormatText->new(leftmargin=>0)->format(
% HTML::TreeBuilder->new_from_content(
-% $cgi->param('html_body')
+% $html_body
% )
% );
<INPUT TYPE="hidden" NAME="text_body" VALUE="<% $text_body |h %>">
<TR>
<TD ALIGN="right" VALIGN="top">Message (Text display): </TD>
- <TD CLASS="background" ALIGN="left"><PRE><% $text_body %></PRE></TD>
+ <TD CLASS="background" STYLE="background-color:white" ALIGN="left"><PRE><% $text_body %></PRE></TD>
</TR>
</TABLE>
-% if ( $cgi->param('magic') eq 'preview' ) {
+% if ( $cgi->param('action') eq 'preview' ) {
<SCRIPT>
function areyousure(href) {
@@ -72,15 +81,29 @@
</SCRIPT>
<BR>
- <INPUT TYPE="hidden" NAME="magic" VALUE="send">
+ <INPUT TYPE="hidden" NAME="action" VALUE="send">
<INPUT TYPE="submit" VALUE="Send notice" onClick="return areyousure()">
% }
% } else {
- <TABLE BGCOLOR="#cccccc" CELLSPACING=0 WIDTH="100%">
+<SCRIPT TYPE="text/javascript">
+function toggle(obj) {
+ document.getElementById('table_no_template').style.display = (obj.value == 0) ? '' : 'none';
+}
+</SCRIPT>
+Template:
+ <% include('/elements/select-table.html',
+ 'label' => 'Template:',
+ 'table' => 'msg_template',
+ 'name_col' => 'msgname',
+ 'empty_label' => '(none)',
+ 'onchange' => 'toggle(this)',
+ )
+ %><BR>
+ <TABLE BGCOLOR="#cccccc" CELLSPACING=0 WIDTH="100%" id="table_no_template">
<% include('/elements/tr-input-text.html',
'field' => 'from',
'label' => 'From:',
@@ -102,15 +125,14 @@
%#Substitution vars:
- <BR><BR>
- <INPUT TYPE="hidden" NAME="magic" VALUE="preview">
+ <INPUT TYPE="hidden" NAME="action" VALUE="preview">
<INPUT TYPE="submit" VALUE="Preview notice">
% }
</FORM>
-% if ( $cgi->param('magic') eq 'send' ) {
+% if ( $cgi->param('action') eq 'send' ) {
<SCRIPT TYPE="text/javascript">
process();
</SCRIPT>
@@ -123,16 +145,32 @@
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Bulk send customer notices');
-my %search = $cgi->Vars;
-delete $search{$_} for qw( magic from subject html_body text_body );
-$search{$_} = [ split(/\0/, $search{$_}) ]
- foreach grep { $_ eq 'payby' || $search{$_} =~ /\0/ } keys %search;
-
-my $title = 'Bulk send customer notices';
+my $table = $cgi->param('table') or die "'table' required";
+my %search;
+if ( $cgi->param('search') ) {
+ %search = %{ thaw(decode_base64($cgi->param('search'))) };
+}
+else {
+ %search = $cgi->Vars;
+ delete $search{$_} for qw( action table from subject html_body text_body );
+ # FS::$table->search is expected to know which parameters might be
+ # multi-valued, and to accept scalar values for them also. No good
+ # solution to this since CGI can't tell whether a parameter _might_
+ # have had multiple values, only whether it does.
+ @search{keys %search} = map { /\0/ ? [ split /\0/, $_ ] : $_ } values %search;
+}
+
+my $title = 'Send bulk customer notices';
my $num_cust;
-if ( $cgi->param('magic') eq 'preview' ) {
- my $sql_query = FS::cust_main->search(\%search);
+my $from = $cgi->param('from') || '';
+my $subject = $cgi->param('subject') || '';
+my $html_body = $cgi->param('html_body') || '';
+
+my $msg_template = '';
+
+if ( $cgi->param('action') eq 'preview' ) {
+ my $sql_query = "FS::$table"->search(\%search);
my $count_query = delete($sql_query->{'count_query'});
my $count_sth = dbh->prepare($count_query)
or die "Error preparing $count_query: ". dbh->errstr;
@@ -140,6 +178,17 @@ if ( $cgi->param('magic') eq 'preview' ) {
or die "Error executing $count_query: ". $count_sth->errstr;
my $count_arrayref = $count_sth->fetchrow_arrayref;
$num_cust = $count_arrayref->[0];
+
+ if ( $cgi->param('msgnum') ) {
+ $msg_template = qsearchs('msg_template',
+ { msgnum => $cgi->param('msgnum') } )
+ or die "template not found: ".$cgi->param('msgnum');
+ $sql_query->{'extra_sql'} .= ' LIMIT 1';
+ $sql_query->{'order_by'} = '';
+ my $cust = qsearchs($sql_query)->cust_main;
+ my %message = $msg_template->prepare( 'cust_main' => $cust );
+ ($from, $subject, $html_body) = @message{'from', 'subject', 'html_body'};
+ }
}
</%init>
diff --git a/httemplate/misc/merge_cust.html b/httemplate/misc/merge_cust.html
new file mode 100644
index 0000000..ad075be
--- /dev/null
+++ b/httemplate/misc/merge_cust.html
@@ -0,0 +1,72 @@
+<% include('/elements/header-popup.html', 'Merge customer' ) %>
+
+<% include('/elements/error.html') %>
+
+<FORM NAME="cust_merge_popup" ID="cust_merge_popup" ACTION="<% popurl(1) %>cust_main-merge.html" METHOD=POST onSubmit="submit_merge(); return false;">
+
+<SCRIPT TYPE="text/javascript">
+
+var submit_interval_id;
+function submit_merge() {
+ document.getElementById('confirm_merge_cust_button').disabled = 'true';
+ smart_new_custnum_search(document.getElementById('new_custnum_search'));
+ submit_interval_id = setInterval( do_submit_merge, 100);
+}
+
+function do_submit_merge() {
+
+ if ( new_custnum_search_active )
+ return;
+
+ document.getElementById('confirm_merge_cust_button').disabled = '';
+
+ clearInterval(submit_interval_id);
+
+ if ( document.cust_merge_popup.new_custnum.value != '' ) {
+ document.cust_merge_popup.submit();
+ }
+
+}
+
+</SCRIPT>
+
+</SCRIPT>
+
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+
+<TABLE BORDER="0" CELLSPACING="2" STYLE="margin-left:auto; margin-right:auto">
+ <% include('/elements/tr-search-cust_main.html',
+ 'label' => 'Merge into: ',
+ 'field' => 'new_custnum',
+ 'find_button' => 1,
+ 'curr_value' => scalar($cgi->param('new_custnum')),
+ )
+ %>
+</TABLE>
+
+<P ALIGN="CENTER">
+%#have merge button start out disabled and enable after you select a target cust
+<INPUT TYPE="submit" NAME="confirm_merge_cust_button" ID="confirm_merge_cust_button" VALUE="Merge customer">&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;<INPUT TYPE="BUTTON" VALUE="Don't merge" onClick="parent.cClick();">
+
+</FORM>
+</BODY>
+</HTML>
+
+<%init>
+
+$cgi->param('custnum') =~ /^(\d+)$/ or die 'illegal custnum';
+my $custnum = $1;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+die "access denied" unless $curuser->access_right('Merge customer');
+
+my $cust_main = qsearchs( {
+ 'table' => 'cust_main',
+ 'hashref' => { 'custnum' => $custnum },
+ 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+} );
+die "No customer # $custnum" unless $cust_main;
+
+</%init>
+
diff --git a/httemplate/misc/order_pkg.html b/httemplate/misc/order_pkg.html
index 33b2bb3..b232deb 100644
--- a/httemplate/misc/order_pkg.html
+++ b/httemplate/misc/order_pkg.html
@@ -9,14 +9,14 @@
function enable_order_pkg () {
if ( document.OrderPkgForm.pkgpart.selectedIndex > 0 ) {
- document.OrderPkgForm.submit.disabled = false;
+ document.OrderPkgForm.submitButton.disabled = false;
if ( document.OrderPkgForm.pkgpart.options[document.OrderPkgForm.pkgpart.selectedIndex].getAttribute('data-can_discount') == 1 ) {
document.OrderPkgForm.discountnum.disabled = false;
} else {
document.OrderPkgForm.discountnum.disabled = true;
}
} else {
- document.OrderPkgForm.submit.disabled = true;
+ document.OrderPkgForm.submitButton.disabled = true;
document.OrderPkgForm.discountnum.disabled = true;
}
}
@@ -38,34 +38,19 @@
)
%>
-%# false laziness w/edit/quick-charge.html
<TR>
<TH ALIGN="right">Start date </TD>
<TD COLSPAN=6>
- <INPUT TYPE = "text"
- NAME = "start_date"
- SIZE = 32
- ID = "start_date_text"
- VALUE = "<% $start_date %>"
- >
- <IMG SRC = "../images/calendar.png"
- ID = "start_date_button"
- STYLE = "cursor: pointer"
- TITLE = "Select date"
- >
+ <% include('/elements/input-date-field.html',{
+ 'name' => 'start_date',
+ 'format' => $date_format,
+ 'value' => $start_date,
+ 'noinit' => 1,
+ }) %>
<FONT SIZE=-1>(leave blank to start immediately)</FONT>
</TD>
</TR>
-<SCRIPT TYPE="text/javascript">
- Calendar.setup({
- inputField: "start_date_text",
- ifFormat: "<% $date_format %>",
- button: "start_date_button",
- align: "BR"
- });
-</SCRIPT>
-
% if ( $cust_main->payby =~ /^(CARD|CHEK)$/ ) {
% my $what = lc(FS::payby->shortname($cust_main->payby));
<TR>
@@ -99,10 +84,30 @@
)
%>
+<TR>
+ <TH ALIGN="right">Contract end date </TD>
+ <TD COLSPAN=6>
+ <% include('/elements/input-date-field.html',{
+ 'name' => 'contract_end',
+ 'format' => $date_format,
+ 'value' => '',
+ 'noinit' => 1,
+ }) %>
+ </TD>
+</TR>
+
</TABLE>
+<% include( '/elements/standardize_locations.html',
+ 'form' => "OrderPkgForm",
+ 'onlyship' => 1,
+ 'no_company' => 1,
+ 'callback' => 'document.OrderPkgForm.submit();',
+ )
+%>
+
<BR>
-<INPUT NAME="submit" TYPE="submit" VALUE="Order Package" <% $pkgpart ? '' : 'DISABLED' %>>
+<INPUT NAME="submitButton" TYPE="button" VALUE="Order Package" onClick = "this.disabled=true; standardize_locations();" <% $pkgpart ? '' : 'DISABLED' %>>
</FORM>
</BODY>
@@ -128,7 +133,10 @@ my $cust_main = qsearchs({
my $pkgpart = scalar($cgi->param('pkgpart'));
my $format = $date_format. ' %T %z (%Z)'; #false laziness w/REAL_cust_pkg.cgi?
-my $start_date = $cust_main->next_bill_date;
-$start_date = $start_date ? time2str($format, $start_date) : '';
+my $start_date = '';
+if( ! $conf->exists('order_pkg-no_start_date') ) {
+ $start_date = $cust_main->next_bill_date;
+ $start_date = $start_date ? time2str($format, $start_date) : '';
+}
</%init>
diff --git a/httemplate/misc/payment.cgi b/httemplate/misc/payment.cgi
index 813b560..bcab68a 100644
--- a/httemplate/misc/payment.cgi
+++ b/httemplate/misc/payment.cgi
@@ -67,6 +67,11 @@
% }
+<% include('/elements/tr-select-discount_term.html',
+ 'custnum' => $custnum,
+ 'cgi' => $cgi
+ )
+%>
% if ( $payby eq 'CARD' ) {
%
diff --git a/httemplate/misc/process/batch-cust_pay.cgi b/httemplate/misc/process/batch-cust_pay.cgi
index 058a225..e51b9e6 100644
--- a/httemplate/misc/process/batch-cust_pay.cgi
+++ b/httemplate/misc/process/batch-cust_pay.cgi
@@ -10,13 +10,33 @@
% #my $row = 0;
% #while ( exists($param->{"custnum$row"}) ) {
% for ( my $row = 0; exists($param->{"custnum$row"}); $row++ ) {
+% my $custnum = $param->{"custnum$row"};
+% my $cust_main;
+% if ( $custnum =~ /^(\d+)$/ and $1 <= 2147483647 ) {
+% $cust_main = qsearchs({
+% 'table' => 'cust_main',
+% 'hashref' => { 'custnum' => $1 },
+% 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+% });
+% }
+% if ( !$cust_main ) { # not found, try agent_custid
+% $cust_main = qsearchs({
+% 'table' => 'cust_main',
+% 'hashref' => { 'agent_custid' => $custnum },
+% 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+% });
+% }
+% $custnum = $cust_main->custnum if $cust_main;
+% # if !$cust_main, then this will throw an error on batch_insert
+%
% push @cust_pay, new FS::cust_pay {
-% 'custnum' => $param->{"custnum$row"},
-% 'paid' => $param->{"paid$row"},
-% 'payby' => 'BILL',
-% 'payinfo' => $param->{"payinfo$row"},
-% 'paybatch' => $paybatch,
-% }
+% 'custnum' => $custnum,
+% 'paid' => $param->{"paid$row"},
+% 'payby' => 'BILL',
+% 'payinfo' => $param->{"payinfo$row"},
+% 'discount_term' => $param->{"discount_term$row"},
+% 'paybatch' => $paybatch,
+% }
% if $param->{"custnum$row"}
% || $param->{"paid$row"}
% || $param->{"payinfo$row"};
@@ -42,6 +62,6 @@
% } else {
%
%
-<% $cgi->redirect(popurl(3). "search/cust_pay.cgi?magic=paybatch;paybatch=$paybatch") %>
+<% $cgi->redirect(popurl(3). "search/cust_pay.html?magic=paybatch;paybatch=$paybatch") %>
% }
diff --git a/httemplate/misc/process/cust_main-import_charges.cgi b/httemplate/misc/process/cust_main-import_charges.cgi
index 3ca6894..bda3e3b 100644
--- a/httemplate/misc/process/cust_main-import_charges.cgi
+++ b/httemplate/misc/process/cust_main-import_charges.cgi
@@ -16,7 +16,8 @@ my $fh = $cgi->upload('csvfile');
my $error = defined($fh)
? FS::cust_main::batch_charge( {
filehandle => $fh,
- 'fields' => [qw( custnum amount pkg )],
+ 'agentnum' => scalar($cgi->param('agentnum')),
+ 'format' => scalar($cgi->param('format')),
} )
: 'No file';
diff --git a/httemplate/misc/process/cust_main_note-import.cgi b/httemplate/misc/process/cust_main_note-import.cgi
index 6aa8b1d..6625e00 100644
--- a/httemplate/misc/process/cust_main_note-import.cgi
+++ b/httemplate/misc/process/cust_main_note-import.cgi
@@ -26,6 +26,7 @@ The following items <% $op eq 'Preview' ? 'would be' : 'were' %> imported. (See
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Import');
+$FS::cust_main::import=1; # the customer records are already in the database
my $date = time;
my $otaker = $FS::CurrentUser::CurrentUser->username;
my $csv = new Text::CSV_XS;
@@ -38,25 +39,27 @@ my @inserted = ();
my @uninserted = ();
for ( my $row = 0; exists($param->{"custnum$row"}); $row++ ) {
if ( $param->{"custnum$row"} ) {
-# my $cust_main_note = new FS::cust_main_note {
-# 'custnum' => $param->{"custnum$row"},
-# '_date' => $date,
-# 'otaker' => $otaker,
-# 'comments' => $param->{"note$row"},
-# };
-# my $error = '';
-# $error = $cust_main_note->insert unless ($op eq "Preview");
- my $cust_main = qsearchs('cust_main',
- { 'custnum' => $param->{"custnum$row"} }
- );
- my $error;
- if ($cust_main) {
- $cust_main->comments
- ? $cust_main->comments($cust_main->comments. " ". $param->{"note$row"})
- : $cust_main->comments($param->{"note$row"});
- $error = $cust_main->replace;
- }else{
- $error = "Can't find customer " . $param->{"custnum$row"};
+ my $error = '';
+ if ( $param->{use_comments} ) { # why? notes are sexier
+ my $cust_main = qsearchs('cust_main',
+ { 'custnum' => $param->{"custnum$row"} }
+ );
+ if ($cust_main) {
+ $cust_main->comments
+ ? $cust_main->comments($cust_main->comments. " ". $param->{"note$row"})
+ : $cust_main->comments($param->{"note$row"});
+ $error = $cust_main->replace;
+ }else{
+ $error = "Can't find customer " . $param->{"custnum$row"};
+ }
+ } else {
+ my $cust_main_note = new FS::cust_main_note {
+ 'custnum' => $param->{"custnum$row"},
+ '_date' => $date,
+ 'otaker' => $otaker,
+ 'comments' => $param->{"note$row"},
+ };
+ $error = $cust_main_note->insert unless ($op eq "Preview");
}
my $result = { 'custnum' => $param->{"custnum$row"},
'last' => $param->{"last$row"},
diff --git a/httemplate/misc/process/cust_pay-import.cgi b/httemplate/misc/process/cust_pay-import.cgi
index d4ff226..92b6e5a 100644
--- a/httemplate/misc/process/cust_pay-import.cgi
+++ b/httemplate/misc/process/cust_pay-import.cgi
@@ -1,4 +1,4 @@
-<% $cgi->redirect(popurl(3). "search/cust_pay.cgi?magic=paybatch;paybatch=$paybatch") %>
+<% $cgi->redirect(popurl(3). "search/cust_pay.html?magic=paybatch;paybatch=$paybatch") %>
<%init>
my $fh = $cgi->upload('csvfile');
diff --git a/httemplate/misc/process/cust_pkg-import.html b/httemplate/misc/process/cust_pkg-import.html
new file mode 100644
index 0000000..1021817
--- /dev/null
+++ b/httemplate/misc/process/cust_pkg-import.html
@@ -0,0 +1,10 @@
+<% $server->process %>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Import');
+
+my $server =
+ new FS::UI::Web::JSRPC 'FS::cust_pkg::Import::process_batch_import', $cgi;
+
+</%init>
diff --git a/httemplate/misc/process/delete-customer.cgi b/httemplate/misc/process/delete-customer.cgi
index d509a5e..1201131 100755
--- a/httemplate/misc/process/delete-customer.cgi
+++ b/httemplate/misc/process/delete-customer.cgi
@@ -28,6 +28,6 @@ if ( $cgi->param('new_custnum') ) {
my $cust_main = qsearchs( 'cust_main', { 'custnum' => $custnum } )
or die "Customer not found: $custnum";
-my $error = $cust_main->delete($new_custnum);
+my $error = $cust_main->delete('new_custnum' => $new_custnum);
</%init>
diff --git a/httemplate/misc/process/email-customers.html b/httemplate/misc/process/email-customers.html
index c54bc6d..de2bb92 100644
--- a/httemplate/misc/process/email-customers.html
+++ b/httemplate/misc/process/email-customers.html
@@ -4,6 +4,6 @@
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Bulk send customer notices');
-my $server = new FS::UI::Web::JSRPC 'FS::cust_main::process_email_search_result', $cgi;
+my $server = new FS::UI::Web::JSRPC 'FS::cust_main_Mixin::process_email_search_result', $cgi;
</%init>
diff --git a/httemplate/misc/process/payment.cgi b/httemplate/misc/process/payment.cgi
index 665001e..c1c9071 100644
--- a/httemplate/misc/process/payment.cgi
+++ b/httemplate/misc/process/payment.cgi
@@ -119,19 +119,26 @@ if ( $payby eq 'CHEK' ) {
die "unknown payby $payby";
}
+$cgi->param('discount_term') =~ /^\d*$/
+ or errorpage("illegal discount_term");
+my $discount_term = $1;
+
my $error = '';
my $paynum = '';
if ( $cgi->param('batch') ) {
- $error = $cust_main->batch_card(
- 'payby' => $payby,
- 'amount' => $amount,
- 'payinfo' => $payinfo,
- 'paydate' => "$year-$month-01",
- 'payname' => $payname,
- map { $_ => $cgi->param($_) }
- @{$payby2fields{$payby}}
- );
+ $error = 'Prepayment discounts not supported with batched payments'
+ if $discount_term;
+
+ $error ||= $cust_main->batch_card(
+ 'payby' => $payby,
+ 'amount' => $amount,
+ 'payinfo' => $payinfo,
+ 'paydate' => "$year-$month-01",
+ 'payname' => $payname,
+ map { $_ => $cgi->param($_) }
+ @{$payby2fields{$payby}}
+ );
errorpage($error) if $error;
} else {
@@ -146,6 +153,7 @@ if ( $cgi->param('batch') ) {
'payunique' => $payunique,
'paycvv' => $paycvv,
'paynum_ref' => \$paynum,
+ 'discount_term' => $discount_term,
map { $_ => $cgi->param($_) } @{$payby2fields{$payby}}
);
errorpage($error) if $error;
diff --git a/httemplate/misc/timeworked.html b/httemplate/misc/timeworked.html
index 46063e8..672fad8 100755
--- a/httemplate/misc/timeworked.html
+++ b/httemplate/misc/timeworked.html
@@ -8,22 +8,11 @@
<THEAD>
<TR>
- <TH>Trans</TH>
<TH COLSPAN="2">Ticket</TH>
- <TH>Time</TH>
+ <TH>Hours</TH>
<TH COLSPAN="2">Customer</TH>
<TH>Multiplier</TH>
</TR>
-
- <TR>
- <TH>#</TH>
- <TH>#</TH>
- <TH>Subject</TH>
- <TH>hours</TH>
- <TH>#</TH>
- <TH>Name</TH>
- <TH></TH>
- </TR>
</THEAD>
<TBODY>
@@ -35,9 +24,9 @@
% my ($custnum, $name) = split(':', pop @customers, 2);
% my $link = $p. 'rt/Ticket/Display.html?id='. $ticketmap{$tr_id}.
% '#txn-'. $tr_id;
+% my $clink = $p. 'view/cust_main.cgi?'. $custnum;
<TR>
- <TD><a href="<% $link %>"><% $tr_id %></a></TD>
<TD><a href="<% $link %>"><% $ticketmap{$tr_id} %></a></TD>
<TD><a href="<% $link %>"><% $ticket{$ticketmap{$tr_id}} |h %></a></TD>
@@ -47,8 +36,8 @@
% }
<TD><% sprintf("%0.2f", $seconds/3600) %></TD>
- <TD ALIGN="right"><% $custnum %></TD>
- <TD ALIGN="right"><% $name %></TD>
+ <TD ALIGN="right"><a href="<% $clink %>"><% $custnum %></a></TD>
+ <TD ALIGN="right"><a href="<% $clink %>"><% $name %></a></TD>
<TD>
<INPUT TYPE="hidden" NAME="transactionid<%$tr_id%>" VALUE="1" >
<INPUT TYPE="hidden" NAME="seconds<%$tr_id%>" VALUE="<% $seconds %>" >
@@ -65,10 +54,11 @@
% foreach ( @customers ) {
% ($custnum, $name) = split(':', $_, 2);
+% $clink = $p. 'view/cust_main.cgi?'. $custnum;
<TR>
- <TD ALIGN="right" COLSPAN="5" ><% $custnum %></TD>
- <TD ALIGN="right"><% $name %></TD>
+ <TD ALIGN="right" COLSPAN="4" ><a href="<% $clink %>"><% $custnum %></a></TD>
+ <TD ALIGN="right"><a href="<% $clink %>"><% $name %></a></TD>
<TD>
% $multiplier = $default_multiplier;
diff --git a/httemplate/misc/unprovision.cgi b/httemplate/misc/unprovision.cgi
index 4ab15fd..6f2c238 100755
--- a/httemplate/misc/unprovision.cgi
+++ b/httemplate/misc/unprovision.cgi
@@ -1,6 +1,8 @@
%if ( $error ) {
% errorpage($error);
-%} else {
+%} elsif ( $pkgnum ) {
+<% $cgi->redirect(popurl(2)."search/cust_pkg_svc.html?svcpart=$svcpart;pkgnum=$pkgnum") %>
+%} else { # $custnum should always exist
<% $cgi->redirect(popurl(2)."view/cust_main.cgi?$custnum") %>
%}
<%init>
@@ -9,18 +11,28 @@ die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Unprovision customer service');
#untaint svcnum
-my($query) = $cgi->keywords;
-$query =~ /^(\d+)$/;
-my $svcnum = $1;
+my @svcnums;
+my ($pkgnum, $svcpart, $custnum);
+if( $cgi->param('svcnum') ) {
+ @svcnums = grep { $_ } map { /^(\d+)$/ && $1 } $cgi->param('svcnum');
+ $pkgnum = $cgi->param('pkgnum');
+ $svcpart = $cgi->param('svcpart');
+ $custnum = $cgi->param('custnum');
+}
+else {
+ @svcnums = map { /^(\d+)$/ && $1 } $cgi->keywords;
+}
-#my $svc_acct = qsearchs('svc_acct',{'svcnum'=>$svcnum});
-#die "Unknown svcnum!" unless $svc_acct;
+my $error = '';
+foreach my $svcnum (@svcnums) {
-my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$svcnum});
-die "Unknown svcnum!" unless $cust_svc;
+ my $cust_svc = qsearchs('cust_svc',{'svcnum'=>$svcnum});
+ die "Unknown svcnum!" unless $cust_svc;
-my $custnum = $cust_svc->cust_pkg->custnum;
+ $custnum ||= $cust_svc->cust_pkg->custnum;
-my $error = $cust_svc->cancel;
+ $error .= $cust_svc->cancel;
+
+}
</%init>
diff --git a/httemplate/misc/xmlhttp-cust_main-address_standardize.html b/httemplate/misc/xmlhttp-cust_main-address_standardize.html
index 3b9e142..d0627cd 100644
--- a/httemplate/misc/xmlhttp-cust_main-address_standardize.html
+++ b/httemplate/misc/xmlhttp-cust_main-address_standardize.html
@@ -28,6 +28,7 @@ if ( $sub eq 'address_standardize' ) {
} );
foreach my $pre ( '', 'ship_' ) {
+ next unless ($pre || !$arg{onlyship});
my($zip5, $zip4) = split('-',$arg{$pre.'zip'});
diff --git a/httemplate/misc/xmlhttp-cust_main-censustract.html b/httemplate/misc/xmlhttp-cust_main-censustract.html
index 9d588d7..3ba68af 100644
--- a/httemplate/misc/xmlhttp-cust_main-censustract.html
+++ b/httemplate/misc/xmlhttp-cust_main-censustract.html
@@ -36,23 +36,32 @@ if ( $sub eq 'censustract' ) {
my $content = $res->content;
my $p = new HTML::TokeParser \$content;
my $viewstate;
+ my $eventvalidation;
while (my $token = $p->get_tag('input') ) {
- next unless $token->[1]->{name} eq '__VIEWSTATE';
- $viewstate = $token->[1]->{value};
- last;
+ if ($token->[1]->{name} eq '__VIEWSTATE') {
+ $viewstate = $token->[1]->{value};
+ }
+ if ($token->[1]->{name} eq '__EVENTVALIDATION') {
+ $eventvalidation = $token->[1]->{value};
+ }
+ last if $viewstate && $eventvalidation;
}
- unless ($viewstate) {
+ unless ($viewstate && $eventvalidation ) {
- $error = "no __VIEWSTATE found";
+ $error = "either no __VIEWSTATE or __EVENTVALIDATION found";
} else {
my($zip5, $zip4) = split('-',$arg{zip});
+ #ugh workaround a mess at ffiec
+ $arg{year} = " $arg{year}" unless $arg{year} = "2010";
my @ffiec_args = (
__VIEWSTATE => $viewstate,
+ __EVENTVALIDATION => $eventvalidation,
ddlbYear => $arg{year},
+ ddlbYear => ' 2009',
txtAddress => $arg{address},
txtCity => $arg{city},
ddlbState => $arg{state},
@@ -62,6 +71,7 @@ if ( $sub eq 'censustract' ) {
warn join("\n", @ffiec_args )
if $DEBUG;
+ push @{ $ua->requests_redirectable }, 'POST';
$res = $ua->request( POST( $url, \@ffiec_args ) );
warn $res->as_string
if $DEBUG > 1;
@@ -74,6 +84,7 @@ if ( $sub eq 'censustract' ) {
my @id = qw( MSACode StateCode CountyCode TractCode );
$content = $res->content;
+ warn $res->content if $DEBUG > 1;
$p = new HTML::TokeParser \$content;
my $prefix = 'UcGeoResult11_lb';
my $compare =
diff --git a/httemplate/misc/xmlhttp-cust_main-discount_terms.cgi b/httemplate/misc/xmlhttp-cust_main-discount_terms.cgi
new file mode 100644
index 0000000..71e2da5
--- /dev/null
+++ b/httemplate/misc/xmlhttp-cust_main-discount_terms.cgi
@@ -0,0 +1,24 @@
+% if ( $sub eq 'discount_terms' ) {
+%
+% my $return = [];
+% my $custnum = $cgi->param('arg');
+% my $cust_main = '';
+% $cust_main = qsearchs({
+% 'table' => 'cust_main',
+% 'hashref' => { 'custnum' => $custnum },
+% 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+% });
+%
+% if ($cust_main) {
+% $return = [ map [ $_, "$_ months" ], $cust_main->discount_terms ];
+% }
+%
+<% objToJson($return) %>
+% }
+<%init>
+
+my $conf = new FS::Conf;
+
+my $sub = $cgi->param('sub');
+
+</%init>
diff --git a/httemplate/misc/xmlhttp-cust_main-search.cgi b/httemplate/misc/xmlhttp-cust_main-search.cgi
index 26e68b5..481bea2 100644
--- a/httemplate/misc/xmlhttp-cust_main-search.cgi
+++ b/httemplate/misc/xmlhttp-cust_main-search.cgi
@@ -2,10 +2,10 @@
%
% my $custnum = $cgi->param('arg');
% my $cust_main = '';
-% if ( $custnum <= 2147483647 ) {
+% if ( $custnum =~ /^(\d+)$/ and $1 <= 2147483647 ) {
% $cust_main = qsearchs({
% 'table' => 'cust_main',
-% 'hashref' => { 'custnum' => $custnum },
+% 'hashref' => { 'custnum' => $1 },
% 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
% });
% }
@@ -22,7 +22,9 @@
% } elsif ( $sub eq 'smart_search' ) {
%
% my $string = $cgi->param('arg');
-% my @cust_main = smart_search( 'search' => $string );
+% my @cust_main = smart_search( 'search' => $string,
+% 'no_fuzzy_on_exact' => 1, #pref?
+% );
% my $return = [ map [ $_->custnum, $_->name ], @cust_main ];
%
<% objToJson($return) %>
diff --git a/httemplate/search/477.html b/httemplate/search/477.html
index 63eab7a..d586406 100755
--- a/httemplate/search/477.html
+++ b/httemplate/search/477.html
@@ -35,7 +35,7 @@
% next unless ( $part{'IIA'} || $part{'IIB'} );
% }
%
-% if ( $part eq 'VI' ) {
+% if ( $part eq 'VI_census' ) {
% next unless $part{'IA'};
% }
%
@@ -55,12 +55,12 @@
% }
% } else {
% if ( $type eq 'xml' ) {
-<<% 'Part_'. uc($part) %>>
+<<% 'Part_'. $part %>>
% }
% my $url = &{$url_mangler}($part);
<% include( "477part${part}.html", 'url' => $url ) %>
% if ( $type eq 'xml' ) {
-</<% 'Part_'. uc($part) %>>
+</<% 'Part_'. $part %>>
% }
% }
% }
@@ -88,6 +88,6 @@ my $url_mangler = sub {
$url =~ s/477\./477part$part./;
$url;
};
-my @parts = qw( IA IIA IIB IV V VI );
+my @parts = qw( IA IIA IIB IV V VI_census );
</%init>
diff --git a/httemplate/search/477partIA_detail.html b/httemplate/search/477partIA_detail.html
index 546d56c..6fea391 100755
--- a/httemplate/search/477partIA_detail.html
+++ b/httemplate/search/477partIA_detail.html
@@ -9,6 +9,7 @@
'disable_total' => 1,
'header' => [ '', @column_option_name ],
'xml_elements' => [ @xml_elements ],
+ 'xml_omit_empty' => 1,
'fields' => [ @fields ],
)
%>
@@ -60,9 +61,19 @@ my $technology = $FS::Report::FCC_477::technology[$tech_code] || 'unknown';
my $html_init = "<H2>Part IA $technology breakdown by speeds</H2>";
my $xml_prefix = 'PartIA_'. chr(65 + $tech_code);
+if ($cgi->param('_type') eq 'xml') {
+ #rotate data pi/2
+ my @temp = @column_option;
+ @column_option = @row_option;
+ @row_option = @temp;
+}
+
my $query = 'SELECT '. join(' UNION ALL SELECT ',@row_option);
my $count_query = 'SELECT '. scalar(@row_option);
+my $xml_element = 'OOPS, I was never set';
+my $rowchar = 101; # 'e' -- rows are columns! (pi/2)
+
my $value = sub {
my ($rowref, $column) = (shift, shift);
my $row = $rowref->[0];
@@ -71,7 +82,7 @@ my $value = sub {
return $row_option_name{$row} || 'no such report option';
} elsif ( $column =~ /^(\d+)$/ ) {
my @report_option = ( $row || '',
- $column_option[$1 - 2] || '',
+ $column_option[$column] || '',
$technology_option[$tech_code] || '',
);
@@ -81,45 +92,35 @@ my $value = sub {
my $percentage = sprintf('%.2f', $count ? 100 * $residential / $count : 0);
my $return = $count;
- $return .= "<BR>$percentage% residential"
- unless $cgi->param('_type') eq 'xml';
+
+ if ($cgi->param('_type') eq 'xml') {
+ $rowchar++ if $column == 0;
+ $xml_element = $xml_prefix. chr($rowchar). ($column+1);
+ $return = '' if $count == 0 and $cgi->param('_type') eq 'xml';
+ } else {
+ $return .= "<BR>$percentage% residential";
+ }
+
return $return;
} else {
return '<FONT SIZE="+1" COLOR="#ff0000">Bad call to column_value</FONT>';
}
};
-my @fields = (
- sub { &{$value}(shift, 'name');},
- sub { &{$value}(shift, 2);},
- sub { &{$value}(shift, 3);},
- sub { &{$value}(shift, 4);},
- sub { &{$value}(shift, 5);},
- sub { &{$value}(shift, 6);},
- sub { &{$value}(shift, 7);},
- sub { &{$value}(shift, 8);},
- sub { &{$value}(shift, 9);},
- );
+my @fields = map { my $ci = $_; sub { &{$value}(shift, $ci); } }
+ ( 'name', (0 .. $#column_option) );
shift @fields if $cgi->param('_type') eq 'xml';
-my $xml_element = sub {
- my ($rowref, $column) = (shift, shift);
- my $row = $rowref->[0];
-
- $row++;
- $xml_prefix. $column. $row;
-
-};
-
-my @xml_elements = (
- sub { &{$xml_element}(shift, 'f') },
- sub { &{$xml_element}(shift, 'g') },
- sub { &{$xml_element}(shift, 'h') },
- sub { &{$xml_element}(shift, 'i') },
- sub { &{$xml_element}(shift, 'j') },
- sub { &{$xml_element}(shift, 'k') },
- sub { &{$xml_element}(shift, 'l') },
- sub { &{$xml_element}(shift, 'm') },
+my @xml_elements = ( # -- columns are rows! (pi/2)
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
+ sub { return $xml_element; },
);
</%init>
diff --git a/httemplate/search/477partIA_summary.html b/httemplate/search/477partIA_summary.html
index 269f2ca..eb1c116 100755
--- a/httemplate/search/477partIA_summary.html
+++ b/httemplate/search/477partIA_summary.html
@@ -26,7 +26,7 @@
sub { '100.00' },
sub { '100.00' },
sub { $total_percentage },
- sub { $total_percentage },
+ sub { $above_200_percentage },
],
)
%>
@@ -54,11 +54,13 @@ my @technology_option = &FS::Report::FCC_477::parse_technology_option($cgi);
my $total_count = 0;
my $total_residential = 0;
+my $above_200 = 0;
my $tech_code = $opt{tech_code};
my $technology = $FS::Report::FCC_477::technology[$tech_code] || 'unknown';
my $html_init = "<H2>Part IA $technology totals</H2>";
my $xml_prefix = 'PartIA_'. chr(65 + $tech_code);
+my $not_first_row = 0; # ugh;
foreach my $row ( @row_option ) {
foreach my $column ( @column_option ) {
@@ -70,11 +72,16 @@ foreach my $row ( @row_option ) {
$total_count += $count;
$total_residential += $residential;
+ $above_200 += $residential if $not_first_row;
}
+ $not_first_row++;
}
my $total_percentage =
sprintf("%.2f", $total_count ? 100*$total_residential/$total_count : 0);
+my $above_200_percentage =
+ sprintf("%.2f", $total_count ? 100*$above_200/$total_count : 0);
+
</%init>
diff --git a/httemplate/search/477partVI.html b/httemplate/search/477partVI_census.html
index db572bc..1d625dc 100755
--- a/httemplate/search/477partVI.html
+++ b/httemplate/search/477partVI_census.html
@@ -1,5 +1,13 @@
<% include( 'elements/search.html',
'html_init' => $html_init,
+ 'html_foot' => sub { if (scalar(keys %state_hash) > 1) {
+ '<BR><B>'.
+ 'WARNING: multiple states found'.
+ '</B><BR>';
+ } else {
+ '';
+ }
+ },
'name' => 'regions',
'query' => [ @sql_query ],
'count_query' => $count_query,
@@ -28,12 +36,15 @@
'percentage',
],
'fields' => [
- sub { my $row = shift; substr($row->censustract, 2, 3) },
+ sub { my $row = shift;
+ $state_hash{substr($row->censustract, 0, 2)} = 1;
+ substr($row->censustract, 2, 3)
+ },
sub { my $row = shift; substr($row->censustract, 5) },
'upload',
'download',
'technology_code',
- sub { '' }, # doesn't really work
+ sub { $cgi->param('_type') eq 'xml' ? '0' : '' }, # doesn't really work
'quantity',
sub { my $row = shift; sprintf "%.2f", $row->residential },
],
@@ -64,6 +75,7 @@ my $html_init = '<H2>Part VI</H2>';
my %search_hash = ();
my @sql_query = ();
+my %state_hash = ();
for ( qw(agentnum magic classnum) ) {
$search_hash{$_} = $cgi->param($_) if $cgi->param($_);
diff --git a/httemplate/search/cdr.html b/httemplate/search/cdr.html
index a557596..5544ff5 100644
--- a/httemplate/search/cdr.html
+++ b/httemplate/search/cdr.html
@@ -55,6 +55,8 @@ die "access denied"
my $edit_data = $FS::CurrentUser::CurrentUser->access_right('Edit rating data');
+my $conf = new FS::Conf;
+
my $areboxes = 0;
my $title = 'Call Detail Records';
@@ -145,17 +147,24 @@ foreach my $param ( grep /^termpart\d+status$/, $cgi->param ) {
}
###
-# src/dest/charged_party
+# src/dest/charged_party/svcnum
###
-if ( $cgi->param('src') =~ /^\s*([\d\-\+\ ]+)\s*$/ ) {
- ( my $src = $1 ) =~ s/\D//g;
+my $phonenum = qr/^\s*([\d\-\+\ ]+)\s*$/;
+my $x = qr/\D/;
+if ( $conf->exists('svc_phone-allow_alpha_phonenum') ) {
+ $phonenum = qr/^\s*([\d\-\+\ A-Za-z]+)\s*$/;
+ $x = qr/[^\dA-Za-z]/;
+}
+
+if ( $cgi->param('src') =~ $phonenum ) {
+ ( my $src = $1 ) =~ s/$x//g;
$hashref->{'src'} = $src;
push @search, "src = '$src'";
}
-if ( $cgi->param('dst') =~ /^\s*([\d\-\+ ]+)\s*$/ ) {
- ( my $dst = $1 ) =~ s/\D//g;
+if ( $cgi->param('dst') =~ $phonenum ) {
+ ( my $dst = $1 ) =~ s/$x//g;
$hashref->{'dst'} = $dst;
push @search, "dst = '$dst'";
}
@@ -166,15 +175,32 @@ if ( $cgi->param('dcontext') =~ /^\s*(.+)\s*$/ ) {
push @search, "dcontext = '$dcontext'";
}
-if ( $cgi->param('charged_party') =~ /^\s*([\d\-\+\ ]+)\s*$/ ) {
- ( my $charged_party = $1 ) =~ s/\D//g;
- #$hashref->{'charged_party'} = $charged_party;
- #push @search, "charged_party = '$charged_party'";
- #XXX countrycode
+if ( $cgi->param('charged_party') ) {
+
+ my @cp = map { $_, "1$_" }
+ split(/\s*,\s*/, $cgi->param('charged_party') );
+
+ my $search = 'charged_party IN ('. join(',', map dbh->quote($_), @cp). ')';
+
+ push @search, $search;
+ push @qsearch, $search;
+}
+
+if ( $cgi->param('charged_party_or_src') ) {
+
+ my @cp = map { $_, "1$_" }
+ split(/\s*,\s*/, $cgi->param('charged_party_or_src') );
+ my $in = join(',', map dbh->quote($_), @cp);
+
+ my $search = "( charged_party IN ($in) OR src IN ($in) )";
- my $search = " ( charged_party = '$charged_party'
- OR charged_party = '1$charged_party' ) ";
+ push @search, $search;
+ push @qsearch, $search;
+}
+if ( $cgi->param('svcnum') =~ /^([\d, ]+)$/ ) {
+ my $svcnum = $1;
+ my $search = "svcnum IN ($svcnum)";
push @search, $search;
push @qsearch, $search;
}
diff --git a/httemplate/search/cust_bill.html b/httemplate/search/cust_bill.html
index 1e9ee8d..cf6ce49 100755
--- a/httemplate/search/cust_bill.html
+++ b/httemplate/search/cust_bill.html
@@ -122,8 +122,16 @@ if ( $cgi->param('invnum') =~ /^\s*(FS-)?(\d+)\s*$/ ) {
$search{'newest_percust'} = 1;
$count_query = "SELECT COUNT(DISTINCT cust_bill.custnum), 'N/A', 'N/A'";
}
-
- my $extra_sql = ' WHERE '. FS::cust_bill->search_sql_where( \%search );
+
+ my $payby_sql = '';
+ $payby_sql = ' AND (' .
+ join(' OR ', map { "cust_main.payby = '$_'" } $cgi->param('payby') ) .
+ ')'
+ if $cgi->param('payby');
+
+ my $extra_sql = ' WHERE '.
+ FS::cust_bill->search_sql_where( \%search ).
+ $payby_sql;
unless ( $count_query ) {
$count_query = 'SELECT COUNT(*), '. join(', ',
diff --git a/httemplate/search/cust_bill_pkg.cgi b/httemplate/search/cust_bill_pkg.cgi
index 98a1da9..f2a5ccd 100644
--- a/httemplate/search/cust_bill_pkg.cgi
+++ b/httemplate/search/cust_bill_pkg.cgi
@@ -502,26 +502,29 @@ if ( $cgi->param('nottax') ) {
} elsif ( $cgi->param('istax') ) {
#false laziness w/report_tax.cgi $taxfromwhere
- if ( $conf->exists('tax-pkg_address') ) {
+ if ( scalar( grep( /locationtaxid/, $cgi->param ) ) ||
+ $cgi->param('iscredit') eq 'rate') {
+
+ $join_pkg .=
+ ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '.
+ ' LEFT JOIN tax_rate_location USING ( taxratelocationnum ) ';
+
+ } elsif ( $conf->exists('tax-pkg_address') ) {
+
$join_pkg .= ' LEFT JOIN cust_bill_pkg_tax_location USING ( billpkgnum )
LEFT JOIN cust_location USING ( locationnum ) ';
#quelle kludge, somewhat false laziness w/report_tax.cgi
s/cust_pkg\.locationnum/cust_bill_pkg_tax_location.locationnum/g for @where;
- } elsif ( scalar( grep( /locationtaxid/, $cgi->param ) ) ||
- $cgi->param('iscredit') eq 'rate') {
- $join_pkg .=
- ' LEFT JOIN cust_bill_pkg_tax_rate_location USING ( billpkgnum ) '.
- ' LEFT JOIN tax_rate_location USING ( taxratelocationnum ) ';
}
if ( $cgi->param('iscredit') ) {
$join_pkg .= ' JOIN cust_credit_bill_pkg USING ( billpkgnum';
- if ( $conf->exists('tax-pkg_address') ) {
+ if ( $cgi->param('iscredit') eq 'rate' ) {
+ $join_pkg .= ', billpkgtaxratelocationnum )';
+ } elsif ( $conf->exists('tax-pkg_address') ) {
$join_pkg .= ', billpkgtaxlocationnum )';
push @where, "billpkgtaxratelocationnum IS NULL";
- } elsif ( $cgi->param('iscredit') eq 'rate' ) {
- $join_pkg .= ', billpkgtaxratelocationnum )';
} else {
$join_pkg .= ' )';
push @where, "billpkgtaxratelocationnum IS NULL";
diff --git a/httemplate/search/cust_bill_pkg_discount.html b/httemplate/search/cust_bill_pkg_discount.html
index 088b291..b472366 100644
--- a/httemplate/search/cust_bill_pkg_discount.html
+++ b/httemplate/search/cust_bill_pkg_discount.html
@@ -85,6 +85,11 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
push @where, "cust_main.agentnum = $1";
}
+#usernum
+if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
+ push @where, "cust_pkg_discount.usernum = $1";
+}
+
# #classnum
# # not specified: all classes
# # 0: empty class
@@ -110,18 +115,25 @@ if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
my $count_query = "SELECT COUNT(*), SUM(amount)";
-my $join_cust = ' JOIN cust_bill_pkg USING ( billpkgnum )
- JOIN cust_bill USING ( invnum )
- LEFT JOIN cust_main USING ( custnum ) ';
+my $join_cust_pkg_discount =
+ 'LEFT JOIN cust_pkg_discount USING (pkgdiscountnum)';
+
+my $join_cust =
+ ' JOIN cust_bill_pkg USING ( billpkgnum )
+ JOIN cust_bill USING ( invnum )
+ LEFT JOIN cust_main USING ( custnum ) ';
-my $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 ';
+my $join_pkg =
+ ' LEFT JOIN cust_pkg ON ( cust_bill_pkg.pkgnum = cust_pkg.pkgnum )
+ LEFT JOIN part_pkg USING ( pkgpart ) ';
+ #LEFT JOIN part_pkg AS override
+ # ON pkgpart_override = override.pkgpart ';
my $where = ' WHERE '. join(' AND ', @where);
-$count_query .= " FROM cust_bill_pkg_discount $join_cust $join_pkg $where";
+$count_query .=
+ " FROM cust_bill_pkg_discount $join_cust_pkg_discount $join_cust $join_pkg ".
+ $where;
my @select = (
'cust_bill_pkg_discount.*',
@@ -135,7 +147,7 @@ push @select, 'cust_main.custnum',
my $query = {
'table' => 'cust_bill_pkg_discount',
- 'addl_from' => "$join_cust $join_pkg",
+ 'addl_from' => "$join_cust_pkg_discount $join_cust $join_pkg",
'hashref' => {},
'select' => join(', ', @select ),
'extra_sql' => $where,
diff --git a/httemplate/search/cust_credit.html b/httemplate/search/cust_credit.html
index 9a14dce..a3b22b1 100755
--- a/httemplate/search/cust_credit.html
+++ b/httemplate/search/cust_credit.html
@@ -3,47 +3,14 @@
'name' => 'credits',
'query' => $sql_query,
'count_query' => $count_query,
- 'count_addl' => [ '$%.2f total credited (gross)', ],
+ 'count_addl' => \@count_addl,
#'redirect' => $link,
- 'header' => [ 'Amount',
- 'Date',
- 'By',
- 'Reason',
- FS::UI::Web::cust_header(),
- ],
- 'fields' => [
- #'crednum',
- sub { sprintf('$%.2f', shift->amount ) },
- sub { time2str('%b %d %Y', shift->_date ) },
- 'otaker',
- 'reason',
- \&FS::UI::Web::cust_fields,
- ],
- #'align' => 'rrrllll',
- 'align' => 'rrll'.FS::UI::Web::cust_aligns(),
- 'links' => [
- '',
- '',
- '',
- '',
- ( map { $_ ne 'Cust. Status' ? $clink : '' }
- FS::UI::Web::cust_header()
- ),
- ],
- 'color' => [
- '',
- '',
- '',
- '',
- FS::UI::Web::cust_colors(),
- ],
- 'style' => [
- '',
- '',
- '',
- '',
- FS::UI::Web::cust_styles(),
- ],
+ 'header' => \@header,
+ 'fields' => \@fields,
+ 'align' => $align,
+ 'links' => \@links,
+ 'color' => \@color,
+ 'style' => \@style,
)
%>
<%init>
@@ -51,13 +18,74 @@
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+my $money_char = FS::Conf->new->config('money_char') || '$';
+
my $title = 'Credit Search Results';
#my( $count_query, $sql_query );
+my $unapplied = $cgi->param('unapplied');
+$title = "Unapplied $title" if $unapplied;
+my $clink = sub {
+ my $cust_bill = shift;
+ $cust_bill->cust_main_custnum
+ ? [ "${p}view/cust_main.cgi?", 'custnum' ]
+ : '';
+};
+
+my (@header, @fields, $align, @links, @color, @style);
+$align = '';
+
+#amount
+push @header, 'Amount';
+push @fields, sub { $money_char .sprintf('%.2f', shift->amount) };
+$align .= 'r';
+push @links, '';
+push @color, '';
+push @style, '';
+
+# unapplied amount
+if ($unapplied) {
+ push @header, 'Unapplied';
+ push @fields, sub { $money_char .sprintf('%.2f', shift->unapplied_amount) };
+ $align .= 'r';
+ push @links, '';
+ push @color, '';
+ push @style, '';
+}
+
+push @header, 'Date',
+ 'By',
+ 'Reason',
+ FS::UI::Web::cust_header(),
+ ;
+push @fields, sub { time2str('%b %d %Y', shift->_date ) },
+ 'otaker',
+ 'reason',
+ \&FS::UI::Web::cust_fields,
+ ;
+$align .= 'rll'.FS::UI::Web::cust_aligns(),
+push @links, '',
+ '',
+ '',
+ ( map { $_ ne 'Cust. Status' ? $clink : '' }
+ FS::UI::Web::cust_header()
+ ),
+ ;
+push @color, '',
+ '',
+ '',
+ FS::UI::Web::cust_colors(),
+ ;
+push @style, '',
+ '',
+ '',
+ FS::UI::Web::cust_styles(),
+ ;
+
my @search = ();
-if ( $cgi->param('otaker') && $cgi->param('otaker') =~ /^([\w\.\-]+)$/ ) {
- push @search, "cust_credit.otaker = '$1'";
+if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
+ push @search, "cust_credit.usernum = $1";
}
if ( $cgi->param('agentnum') && $cgi->param('agentnum') =~ /^(\d+)$/ ) {
@@ -67,6 +95,10 @@ if ( $cgi->param('agentnum') && $cgi->param('agentnum') =~ /^(\d+)$/ ) {
$title = $agent->agent. " $title";
}
+if ( $unapplied ) {
+ push @search, FS::cust_credit->unapplied_sql . ' > 0';
+}
+
my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi);
push @search, "_date >= $beginning ",
"_date <= $ending";
@@ -76,29 +108,33 @@ push @search, FS::UI::Web::parse_lt_gt($cgi, 'amount' );
#here is the agent virtualization
push @search, $FS::CurrentUser::CurrentUser->agentnums_sql;
+my @select = (
+ 'cust_credit.*',
+ 'cust_main.custnum as cust_main_custnum',
+ FS::UI::Web::cust_sql_fields(),
+);
+
+if ( $unapplied ) {
+ push @select, '('.FS::cust_credit->unapplied_sql .') AS unapplied_amount';
+ push @search, FS::cust_credit->unapplied_sql .' > 0';
+}
+
my $where = 'WHERE '. join(' AND ', @search);
-my $count_query = 'SELECT COUNT(*), SUM(amount) '.
- 'FROM cust_credit LEFT JOIN cust_main USING ( custnum ) '.
+my $count_query = 'SELECT COUNT(*), SUM(amount) ';
+$count_query .= ', SUM(' . FS::cust_credit->unapplied_sql . ') ' if $unapplied;
+$count_query .= 'FROM cust_credit LEFT JOIN cust_main USING ( custnum ) '.
$where;
+my @count_addl = ( $money_char.'%.2f total credited (gross)' );
+push @count_addl, $money_char.'%.2f unapplied' if $unapplied;
+
my $sql_query = {
'table' => 'cust_credit',
- 'select' => join(', ',
- 'cust_credit.*',
- 'cust_main.custnum as cust_main_custnum',
- FS::UI::Web::cust_sql_fields(),
- ),
+ 'select' => join(', ',@select),
'hashref' => {},
'extra_sql' => $where,
'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
};
- my $clink = sub {
- my $cust_bill = shift;
- $cust_bill->cust_main_custnum
- ? [ "${p}view/cust_main.cgi?", 'custnum' ]
- : '';
- };
-
</%init>
diff --git a/httemplate/search/cust_main.cgi b/httemplate/search/cust_main.cgi
index e65dc71..8fbf636 100755
--- a/httemplate/search/cust_main.cgi
+++ b/httemplate/search/cust_main.cgi
@@ -204,7 +204,9 @@
% if ( $cgi->param('search_cust') ) {
% $sortby = \*company_sort;
% $orderby = "ORDER BY LOWER(company || ' ' || last || ' ' || first )";
-% push @cust_main, smart_search( 'search' => $cgi->param('search_cust') );
+% push @cust_main, smart_search( 'search' => $cgi->param('search_cust'),
+% 'no_fuzzy_on_exact' => 1, #pref?
+% );
% }
%
% @cust_main = grep { $_->ncancelled_pkgs || ! $_->all_pkgs } @cust_main
@@ -270,7 +272,7 @@
% $cgi->param('offset', 0);
% print qq!( <a href="!. $cgi->self_url. qq!">hide!;
% }
-% print ' cancelled customers</a> )';
+% print ' canceled customers</a> )';
% }
%
% if ( $cgi->param('referral_custnum') ) {
@@ -634,7 +636,7 @@
% }
%
% if ( $last_type{'Fuzzy'} || $last_type{'All'} ) {
-% push @cust_main, FS::cust_main->fuzzy_search( { 'last' => $last } );
+% push @cust_main, FS::cust_main::Search->fuzzy_search( { 'last' => $last } );
% }
%
% #if ($last_type{'Sound-alike'}) {
@@ -681,7 +683,7 @@
% }
%
% if ( $company_type{'Fuzzy'} || $company_type{'All'} ) {
-% push @cust_main, FS::cust_main->fuzzy_search( { 'company' => $company } );
+% push @cust_main, FS::cust_main::Search->fuzzy_search( { 'company' => $company } );
% }
%
% if ($company_type{'Sound-alike'}) {
diff --git a/httemplate/search/cust_main.html b/httemplate/search/cust_main.html
index 270fc38..04ecf89 100755
--- a/httemplate/search/cust_main.html
+++ b/httemplate/search/cust_main.html
@@ -44,8 +44,10 @@ my %search_hash = ();
#scalars
my @scalars = qw (
- agentnum status cancelled_pkgs cust_fields flattened_pkgs custbatch usernum
- no_censustract paydate_year paydate_month invoice_terms
+ agentnum status address paydate_year paydate_month invoice_terms
+ no_censustract with_geocode custbatch usernum
+ cancelled_pkgs
+ cust_fields flattened_pkgs
);
for my $param ( @scalars ) {
@@ -54,7 +56,7 @@ for my $param ( @scalars ) {
}
#lists
-for my $param (qw( classnum payby )) {
+for my $param (qw( classnum payby tagnum )) {
$search_hash{$param} = [ $cgi->param($param) ];
}
@@ -84,7 +86,7 @@ $search_hash{'current_balance'} =
# etc
###
-my $sql_query = FS::cust_main->search(\%search_hash);
+my $sql_query = FS::cust_main::Search->search(\%search_hash);
my $count_query = delete($sql_query->{'count_query'});
my @extra_headers = @{ delete($sql_query->{'extra_headers'}) };
my @extra_fields = @{ delete($sql_query->{'extra_fields'}) };
@@ -104,7 +106,7 @@ if ( $FS::CurrentUser::CurrentUser->access_right('Bulk send customer notices') )
my $query = $uri->query;
push @$menubar, 'Email a notice to these customers' =>
- "${p}misc/email-customers.html?$query",
+ "${p}misc/email-customers.html?table=cust_main&$query",
}
diff --git a/httemplate/search/cust_pay.cgi b/httemplate/search/cust_pay.html
index 65bd39e..65bd39e 100755
--- a/httemplate/search/cust_pay.cgi
+++ b/httemplate/search/cust_pay.html
diff --git a/httemplate/search/cust_pay_pending.html b/httemplate/search/cust_pay_pending.html
index f46e08a..8b73508 100755
--- a/httemplate/search/cust_pay_pending.html
+++ b/httemplate/search/cust_pay_pending.html
@@ -19,7 +19,7 @@ my %statusaction = (
'new' => 'delete',
'pending' => 'complete',
#'authorized' => '',
- #'captured' => '',
+ 'captured' => 'capture',
#'declined' => '',
#wouldn't need to take action on a done state#'done'
);
diff --git a/httemplate/search/cust_pkg.cgi b/httemplate/search/cust_pkg.cgi
index 74a3a6d..207e4f6 100755
--- a/httemplate/search/cust_pkg.cgi
+++ b/httemplate/search/cust_pkg.cgi
@@ -19,6 +19,7 @@
'Adjourn',
'Susp.',
'Expire',
+ 'Contract end',
'Cancel',
'Reason',
FS::UI::Web::cust_header(
@@ -59,7 +60,7 @@
#sub { time2str('%b %d %Y', shift->expire); },
#sub { time2str('%b %d %Y', shift->get('cancel')); },
( map { time_or_blank($_) }
- qw( setup last_bill bill adjourn susp expire cancel ) ),
+ qw( setup last_bill bill adjourn susp expire contract_end cancel ) ),
sub { my $self = shift;
my $return = '';
@@ -175,8 +176,9 @@ my %search_hash = ();
#some false laziness w/misc/bulk_change_pkg.cgi
$search_hash{'query'} = $cgi->keywords;
-
-for (qw( agentnum custnum magic status classnum custom cust_fields )) {
+
+#scalars
+for (qw( agentnum custnum magic status classnum custom cust_fields pkgbatch )) {
$search_hash{$_} = $cgi->param($_) if $cgi->param($_);
}
@@ -205,7 +207,7 @@ my %disable = (
'' => {},
);
-foreach my $field (qw( setup last_bill bill adjourn susp expire cancel active )) {
+foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel active )) {
my($beginning, $ending) = FS::UI::Web::parse_beginning_ending($cgi, $field);
@@ -289,6 +291,10 @@ my $html_init = sub {
'height' => 210,
). '<BR>';
}
+ $text .= include( '/elements/email-link.html',
+ 'search_hash' => \%search_hash,
+ 'table' => 'cust_pkg',
+ );
}
return $text;
};
diff --git a/httemplate/search/cust_pkg_discount.html b/httemplate/search/cust_pkg_discount.html
index 233345e..d70c311 100644
--- a/httemplate/search/cust_pkg_discount.html
+++ b/httemplate/search/cust_pkg_discount.html
@@ -78,9 +78,9 @@ if ( $cgi->param('status') eq 'active' ) {
"; #XXX also end date
}
-#otaker
-if ( $cgi->param('otaker') && $cgi->param('otaker') =~ /^([\w\.\-]+)$/ ) {
- push @where, "cust_pkg_discount.otaker = '$1'";
+#usernum
+if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
+ push @where, "cust_pkg_discount.usernum = $1";
}
#agent
diff --git a/httemplate/search/cust_pkg_summary.cgi b/httemplate/search/cust_pkg_summary.cgi
new file mode 100644
index 0000000..fc71c81
--- /dev/null
+++ b/httemplate/search/cust_pkg_summary.cgi
@@ -0,0 +1,87 @@
+<% include('/elements/header.html', $title) %>
+<% include('/elements/table-grid.html') %>
+ <TR>
+% foreach (@head) {
+ <TH CLASS="grid" BGCOLOR="#cccccc"><% $_ %></TH>
+% }
+ </TR>
+% my $r=0;
+% foreach my $row (@rows) {
+ <TR>
+% foreach (@$row) {
+ <TD CLASS="grid" ALIGN="right" BGCOLOR="<% $r % 2 ? '#ffffff' : '#eeeeee' %>"><% $_ %></TD>
+% }
+ </TR>
+% $r++;
+% }
+ <TR>
+% foreach (@totals) {
+ <TD CLASS="grid" ALIGN="right" BGCOLOR="<% $r % 2 ? '#ffffff' : '#eeeeee' %>"><B><% $_ %></B></TD>
+% }
+ </TR>
+</TABLE>
+<%init>
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('List packages');
+
+my $title = 'Package Summary Report';
+my ($begin, $end) = FS::UI::Web::parse_beginning_ending($cgi);
+if($begin > 0) {
+ $title = "$title (".
+ $cgi->param('beginning').' - '.$cgi->param('ending').')';
+}
+
+my @h_sql = FS::h_cust_pkg->sql_h_search($end);
+
+my ($end_sql, $addl_from) = @h_sql[1,3];
+$end_sql =~ s/ORDER BY.*//; # breaks aggregate queries
+
+my $begin_sql = $end_sql;
+$begin_sql =~ s/$end/$begin/g;
+
+my $active_sql = FS::cust_pkg->active_sql;
+my $suspended_sql = FS::cust_pkg->suspended_sql;
+my @conds = (
+ # SQL WHERE clauses for each column of the table.
+ " $begin_sql AND ($active_sql OR $suspended_sql)",
+ '',
+ " $end_sql AND ($active_sql OR $suspended_sql)",
+ " $end_sql AND $active_sql",
+ " $end_sql AND $suspended_sql",
+ );
+
+$_ =~ s/\bcust_pkg/maintable/g foreach @conds;
+
+my @head = ('Package', 'Before Period', 'Sales', 'Total', 'Active', 'Suspended');
+my @rows = ();
+my @totals = ('Total', 0, 0, 0, 0, 0);
+
+if( !$begin ) {
+ splice @conds, 1, 1;
+ splice @head, 1, 1;
+}
+
+foreach my $part_pkg (qsearch('part_pkg', {} )) {
+ my @row = ();
+ next if !$part_pkg->freq; # exclude one-time packages
+ push @row, $part_pkg->pkg;
+ my $i=1;
+ foreach my $cond (@conds) {
+ if($cond) {
+ my $result = qsearchs({
+ 'table' => 'h_cust_pkg',
+ 'hashref' => {},
+ 'select' => 'count(*)',
+ 'addl_from' => $addl_from,
+ 'extra_sql' => 'WHERE pkgpart = '.$part_pkg->pkgpart.$cond,
+ });
+ $row[$i] = $result->getfield('count');
+ $totals[$i] += $row[$i];
+ }
+ $i++;
+ }
+ $row[2] = $row[3]-$row[1];
+ $totals[2] += $row[2];
+ push @rows, \@row;
+}
+</%init>
diff --git a/httemplate/search/cust_pkg_summary.html b/httemplate/search/cust_pkg_summary.html
new file mode 100644
index 0000000..a0ef472
--- /dev/null
+++ b/httemplate/search/cust_pkg_summary.html
@@ -0,0 +1,24 @@
+<% include( '/elements/header.html', 'Package Summary Report' ) %>
+
+<FORM ACTION="cust_pkg_summary.cgi" METHOD="GET">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+ <TR>
+ <TH CLASS="background" COLSPAN=2 ALIGN="left">
+ <FONT SIZE="+1">Search options</FONT>
+ </TH>
+ </TR>
+
+ <% include ('/elements/tr-input-beginning_ending.html') %>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Get Report">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+<%init>
+</%init>
diff --git a/httemplate/search/cust_pkg_susp.cgi b/httemplate/search/cust_pkg_susp.cgi
new file mode 100644
index 0000000..53631a2
--- /dev/null
+++ b/httemplate/search/cust_pkg_susp.cgi
@@ -0,0 +1,107 @@
+<% include('/elements/header.html', $title) %>
+<% include('/elements/table-grid.html') %>
+ <TR>
+% foreach (@head) {
+ <TH CLASS="grid" BGCOLOR="#cccccc"><% $_ %></TH>
+% }
+ </TR>
+% my $r=0;
+% foreach my $row (@rows) {
+ <TR>
+% foreach (@$row) {
+ <TD CLASS="grid" STYLE="border: 1px solid #cccccc" ALIGN="right" BGCOLOR="<% $r % 2 ? '#ffffff' : '#eeeeee' %>"><% $_ %></TD>
+% }
+ </TR>
+% $r++;
+% }
+ <TR>
+% foreach (@totals) {
+ <TD CLASS="grid" STYLE="border: 1px solid #cccccc" ALIGN="right" BGCOLOR="<% $r % 2 ? '#ffffff' : '#eeeeee' %>"><B><% $_ %></B></TD>
+% }
+ </TR>
+</TABLE>
+<%init>
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('List packages');
+
+my $money_char = FS::Conf->new()->config('money_char') || '$';
+
+$FS::Record::DEBUG=0;
+
+my $title = 'Suspension/Unsuspension Report';
+my ($begin, $end) = FS::UI::Web::parse_beginning_ending($cgi);
+if($begin > 0) {
+ $title = "$title (".
+ ($cgi->param('beginning') || 'beginning').' - '.
+ ($cgi->param('ending') || 'present').')';
+}
+
+
+my $begin_sql = $begin ? "AND h2.history_date > $begin" : '';
+my $end_sql = $end ? "AND h2.history_date < $end" : '';
+
+my $h_sql = # self-join FTW!
+"SELECT h1.pkgpart, count(h1.pkgnum) as pkgcount
+ FROM h_cust_pkg AS h1 INNER JOIN h_cust_pkg AS h2 ON (h1.pkgnum = h2.pkgnum)
+ WHERE h1.history_action = 'replace_old' AND h2.history_action = 'replace_new'
+ AND h2.historynum - h1.historynum = 1
+ $begin_sql $end_sql";
+# This assumes replace_old and replace_new records get consecutive
+# numbers. That's true in every case I've seen but is not actually
+# enforced anywhere. If this is a problem we can match them up
+# line by line but that's cumbersome.
+
+my @conds = (
+ '(h1.susp is null OR h1.susp = 0) AND (h2.susp is not null AND h2.susp != 0)',
+ '(h1.susp is not null AND h1.susp != 0) AND (h2.susp is null OR h2.susp = 0)',
+);
+
+my @results;
+foreach my $cond (@conds) {
+ my $sql = "$h_sql AND $cond GROUP BY h1.pkgpart";
+ my $sth = dbh->prepare($sql) or die dbh->errstr;
+ $sth->execute() or die $sth->errstr;
+ push @results, { map { @$_ } @{ $sth->fetchall_arrayref() } };
+}
+
+my @pay_cond;
+push @pay_cond, "cust_bill_pay._date < $end" if $end;
+push @pay_cond, "cust_bill_pay._date > $begin" if $begin;
+
+my $pay_cond = '';
+$pay_cond = 'WHERE '.join(' AND ', @pay_cond) if @pay_cond;
+
+my $pkg_payments = {
+ map { $_->pkgpart => $_->total_pay }
+ qsearch({
+ 'table' => 'cust_pkg',
+ 'select' => 'pkgpart, sum(cust_bill_pay_pkg.amount) AS total_pay',
+ 'addl_from' => 'INNER JOIN cust_bill_pkg USING (pkgnum)
+ INNER JOIN cust_bill_pay_pkg USING (billpkgnum)
+ INNER JOIN cust_bill_pay USING (billpaynum)',
+ 'extra_sql' => $pay_cond . ' GROUP BY pkgpart',
+}) };
+
+my @head = ('Package', 'Suspended', 'Unsuspended', 'Payments');
+my @rows = ();
+my @totals = map {0} @head;
+$totals[0] = 'Total';
+
+foreach my $part_pkg (qsearch('part_pkg', {} )) {
+ my @row = ();
+ next if !$part_pkg->freq; # exclude one-time packages
+ my $pkgpart = $part_pkg->pkgpart;
+ push @row,
+ $part_pkg->pkg,
+ $results[0]->{$pkgpart} || 0,
+ $results[1]->{$pkgpart} || 0,
+ sprintf("%.02f",$pkg_payments->{$pkgpart});
+
+ $totals[$_] += $row[$_] foreach (1..3);
+ $row[3] = $money_char.$row[3];
+
+ push @rows, \@row;
+}
+$totals[3] = $money_char.$totals[3];
+
+</%init>
diff --git a/httemplate/search/cust_pkg_susp.html b/httemplate/search/cust_pkg_susp.html
new file mode 100644
index 0000000..c59e6c1
--- /dev/null
+++ b/httemplate/search/cust_pkg_susp.html
@@ -0,0 +1,24 @@
+<% include( '/elements/header.html', 'Suspension/Reactivation Report' ) %>
+
+<FORM ACTION="cust_pkg_susp.cgi" METHOD="GET">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+ <TR>
+ <TH CLASS="background" COLSPAN=2 ALIGN="left">
+ <FONT SIZE="+1">Search options</FONT>
+ </TH>
+ </TR>
+
+ <% include ('/elements/tr-input-beginning_ending.html') %>
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Get Report">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+<%init>
+</%init>
diff --git a/httemplate/search/cust_pkg_svc.html b/httemplate/search/cust_pkg_svc.html
new file mode 100644
index 0000000..4f27d66
--- /dev/null
+++ b/httemplate/search/cust_pkg_svc.html
@@ -0,0 +1,117 @@
+<% include( 'elements/search.html',
+ 'title' => $part_svc->svc.' services in package #'.$pkgnum,
+ 'name' => 'services',
+ 'html_form' => $html_form,
+ 'query' => $sql_query,
+ 'count_query' => $count_query,
+ 'redirect' => $link,
+ 'header' => [ '#',
+ 'Service',
+ '', #checkboxes
+ ],
+ 'fields' => [ 'svcnum',
+ sub {
+ ($_[0]->label)[1]
+ },
+ sub {
+ $areboxes = 1;
+ '<INPUT TYPE="checkbox" NAME="svcnum" VALUE='.$_[0]->svcnum.'>'
+ },
+ ],
+ 'links' => [ $link,
+ $link,
+ '',
+ ],
+ 'align' => 'rrlc',
+ 'color' => [
+ ('')x4,
+ ],
+ 'style' => [
+ ('')x4,
+ ],
+ 'html_foot' => sub { $areboxes ? $html_foot : '' }
+ )
+%>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('List services');
+
+my $pkgnum = $cgi->param('pkgnum');
+$pkgnum =~ /^(\d+)$/ or die "invalid pkgnum: $pkgnum";
+my @extra_sql = ( "cust_svc.pkgnum = $pkgnum" );
+
+my $svcpart = $cgi->param('svcpart');
+$svcpart =~ /^(\d+)$/ or die "invalid svcpart: $svcpart";
+push @extra_sql, "cust_svc.svcpart = $svcpart";
+my $part_svc = qsearchs('part_svc', {svcpart => $svcpart});
+my $svcdb = $part_svc->svcdb;
+
+my $orderby = 'ORDER BY svcnum'; #others?
+
+my $addl_from = " LEFT JOIN part_svc USING (svcpart)
+LEFT JOIN cust_pkg USING (pkgnum)
+LEFT JOIN cust_main USING (custnum)
+INNER JOIN $svcdb USING (svcnum)";
+
+my $search_string;
+if ( length( $cgi->param('search_svc') ) ) {
+
+ $search_string = $cgi->param('search_svc');
+ $search_string =~ s/(^\s+|\s+$)//;
+ push @extra_sql, "FS::$svcdb"->search_sql($search_string);
+
+}
+
+#here is the agent virtualization
+push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql(
+ 'null_right' => 'View/link unlinked services'
+ );
+
+my $extra_sql = ' WHERE '. join(' AND ', @extra_sql );
+
+my $sql_query = {
+ 'select' => join(', ',
+ 'cust_svc.*',
+ 'part_svc.svc',
+ ),
+ 'table' => 'cust_svc',
+ 'addl_from' => $addl_from,
+ 'hashref' => {},
+ 'extra_sql' => "$extra_sql $orderby",
+};
+
+#warn Dumper($sql_query)."\n";
+
+my $count_query = "SELECT COUNT(*) FROM cust_svc $addl_from $extra_sql";
+
+my $link = sub {
+ my $cust_svc = shift;
+ my $url = svc_url(
+ 'm' => $m,
+ 'action' => 'view',
+ 'svcdb' => $svcdb,
+ 'query' => '',
+ );
+ [ $url, 'svcnum' ];
+};
+
+my $html_form = qq!
+<SCRIPT TYPE="text/javascript">
+function areyousure(obj) {
+ return confirm('Permanently delete the selected services?');
+}
+</SCRIPT>
+<FORM METHOD="POST" ACTION="${p}misc/unprovision.cgi" onsubmit="return areyousure()">!;
+
+my $areboxes = 0;
+
+my $html_foot = qq!
+<BR>
+<INPUT TYPE="submit" NAME="submit" VALUE="Unprovision selected">
+<INPUT TYPE="hidden" NAME="pkgnum" VALUE=$pkgnum>
+<INPUT TYPE="hidden" NAME="svcpart" VALUE=$svcpart>
+</FORM>!;
+
+
+</%init>
diff --git a/httemplate/search/elements/cust_main_dayranges.html b/httemplate/search/elements/cust_main_dayranges.html
index 9b8b08f..91e039d 100644
--- a/httemplate/search/elements/cust_main_dayranges.html
+++ b/httemplate/search/elements/cust_main_dayranges.html
@@ -145,8 +145,12 @@ unless ( $cgi->param('all_customers') ) {
$days = $1;
}
+ # If this is set, allow cust_main records with nonzero balances
+ my $negative = $cgi->param('negative') || 0;
+
push @where,
- call_range_sub($range_sub, $days, 0, 'offset' => $offset, 'no_as'=>1). ' > 0'; # != 0';
+ call_range_sub($range_sub, $days, 0, 'offset' => $offset, 'no_as'=>1).
+ ($negative ? ' != 0' : ' > 0');
}
if ( $cgi->param('agentnum') =~ /^(\d+)$/ ) {
diff --git a/httemplate/search/elements/cust_pay_or_refund.html b/httemplate/search/elements/cust_pay_or_refund.html
index 4f83d0a..6f4aaf8 100755
--- a/httemplate/search/elements/cust_pay_or_refund.html
+++ b/httemplate/search/elements/cust_pay_or_refund.html
@@ -44,7 +44,7 @@ Examples:
'name_singular' => $name_singular,
'query' => $sql_query,
'count_query' => $count_query,
- 'count_addl' => [ '$%.2f total '.$opt{name_verb}, ],
+ 'count_addl' => \@count_addl,
'redirect_empty' => $opt{'redirect_empty'},
'header' => \@header,
'fields' => \@fields,
@@ -68,7 +68,10 @@ my $table = $opt{'table'} || 'cust_'.$opt{'thing'};
my $amount_field = $opt{'amount_field'};
my $name_singular = $opt{'name_singular'};
-my $title = "\u$name_singular Search Results";
+my $unapplied = $cgi->param('unapplied');
+my $title = '';
+$title = 'Unapplied ' if $unapplied;
+$title .= "\u$name_singular Search Results";
my $link = '';
if ( ( $curuser->access_right('View invoices') #XXX for now
@@ -100,6 +103,36 @@ my $cust_link = sub {
: '';
};
+# only valid for $table == 'cust_pay' atm
+my $tax_names = '';
+if ( $cgi->param('tax_names') ) {
+ if ( dbh->{Driver}->{Name} eq 'Pg' ) {
+
+ $tax_names = "
+ array_to_string(
+ array(
+ SELECT itemdesc
+ FROM cust_bill_pay
+ LEFT JOIN cust_bill_pay_pkg USING ( billpaynum )
+ LEFT JOIN cust_bill_pkg USING ( billpkgnum )
+ WHERE cust_bill_pkg.pkgnum = 0
+ AND cust_bill_pay.paynum = cust_pay.paynum
+ ), '|'
+ ) AS tax_names"
+ ;
+
+ } elsif ( dbh->{Driver}->{Name} =~ /^mysql/i ) {
+
+ $tax_names = "GROUP_CONCAT(itemdesc SEPARATOR '|') AS tax_names";
+
+ } else {
+
+ warn "warning: unknown database type ". dbh->{Driver}->{Name}.
+ "omitting tax name information from report.";
+
+ }
+}
+
my @header = ();
my @fields = ();
my $align = '';
@@ -113,15 +146,25 @@ if ( $opt{'pre_header'} ) {
push @header, "\u$name_singular",
'Amount',
- 'Date',
;
-$align .= 'rrr';
-push @links, '', '', '';
+$align .= 'rr';
+push @links, '', '';
push @fields, 'payby_payinfo_pretty',
sub { sprintf('$%.2f', shift->$amount_field() ) },
- sub { time2str('%b %d %Y', shift->_date ) },
;
+if ( $unapplied ) {
+ push @header, 'Unapplied';
+ $align .= 'r';
+ push @links, '';
+ push @fields, sub { sprintf('$%.2f', shift->unapplied_amount) };
+}
+
+push @header, 'Date';
+$align .= 'r';
+push @links, '';
+push @fields, sub { time2str('%b %d %Y', shift->_date ) };
+
unless ( $opt{'disable_by'} ) {
push @header, 'By';
$align .= 'c';
@@ -133,6 +176,22 @@ unless ( $opt{'disable_by'} ) {
};
}
+if ( $tax_names ) {
+ push @header, ('Tax names', 'Tax province');
+ $align .= 'cc';
+ push @links, ('','');
+ push @fields, sub { join (' + ', map { /^(.*?)(, \w\w)?$/; $1 }
+ split('\|', shift->tax_names)
+ );
+ };
+ push @fields, sub { join (' + ', map { if (/^(?:.*)(?:, )(\w\w)$/){ $1 }
+ else { () }
+ }
+ split('\|', shift->tax_names)
+ );
+ };
+}
+
push @header, FS::UI::Web::cust_header();
$align .= FS::UI::Web::cust_aligns();
push @links, map { $_ ne 'Cust. Status' ? $cust_link : '' }
@@ -146,10 +205,17 @@ push @header, @{ $opt{'addl_header'} }
push @fields, @{ $opt{'addl_fields'} }
if $opt{'addl_fields'};
-my( $count_query, $sql_query );
+my( $count_query, $sql_query, @count_addl );
if ( $cgi->param('magic') ) {
my @search = ();
+ my @select = (
+ "$table.*",
+ FS::UI::Web::cust_sql_fields(),
+ 'cust_main.custnum AS cust_main_custnum',
+ );
+ push @select, $tax_names if $tax_names;
+
my $orderby;
if ( $cgi->param('magic') eq '_date' ) {
@@ -245,8 +311,8 @@ if ( $cgi->param('magic') ) {
push @search, "$table.payinfo = '$1'";
}
- if ( $cgi->param('otaker') =~ /^(\w+)$/ ) {
- push @search, "$table.otaker = '$1'";
+ if ( $cgi->param('usernum') =~ /^(\d+)$/ ) {
+ push @search, "$table.usernum = $1";
}
#for cust_pay_pending... statusNOT=done
@@ -282,6 +348,13 @@ if ( $cgi->param('magic') ) {
die "unknown search magic: ". $cgi->param('magic');
}
+ #unapplied payment/refund
+ if ( $unapplied ) {
+ push @select, '(' . "FS::$table"->unapplied_sql . ') AS unapplied_amount';
+ push @search, "FS::$table"->unapplied_sql . ' > 0';
+
+ }
+
#for the history search
if ( $cgi->param('history_action') =~ /^([\w,]+)$/ ) {
my @history_action = split(/,/, $1);
@@ -300,22 +373,49 @@ if ( $cgi->param('magic') ) {
#here is the agent virtualization
push @search, $curuser->agentnums_sql;
+ my $addl_from = ' LEFT JOIN cust_main USING ( custnum ) ';
+ my $group_by = '';
+
+ if ( $cgi->param('tax_names') ) {
+ if ( dbh->{Driver}->{Name} eq 'Pg' ) {
+
+ 0;#twiddle thumbs
+
+ } elsif ( dbh->{Driver}->{Name} =~ /^mysql/i ) {
+
+ $addl_from .= "LEFT JOIN cust_bill_pay USING ( paynum )
+ LEFT JOIN cust_bill_pay_pkg USING ( billpaynum )
+ LEFT JOIN cust_bill_pkg USING ( billpkgnum ) AS tax_names";
+ $group_by .= "GROUP BY $table.*,cust_main_custnum,".
+ FS::UI::Web::cust_sql_fields();
+ push @search,
+ "( cust_bill_pkg.pkgnum = 0 OR cust_bill_pkg.pkgnum is NULL )";
+
+ } else {
+
+ warn "warning: unknown database type ". dbh->{Driver}->{Name}.
+ "omitting tax name information from report.";
+
+ }
+ }
+
my $search = ' WHERE '. join(' AND ', @search);
- $count_query = "SELECT COUNT(*), SUM($amount_field) ".
- "FROM $table LEFT JOIN cust_main USING ( custnum )".
- $search;
+ $count_query = "SELECT COUNT(*), SUM($amount_field) ";
+ $count_query .= ', SUM(' . "FS::$table"->unapplied_sql . ') '
+ if $unapplied;
+ $count_query .= "FROM $table $addl_from".
+ "$search $group_by";
+
+ @count_addl = ( '$%.2f total '.$opt{name_verb} );
+ push @count_addl, '$%.2f unapplied' if $unapplied;
$sql_query = {
'table' => $table,
- 'select' => join(', ',
- "$table.*",
- 'cust_main.custnum as cust_main_custnum',
- FS::UI::Web::cust_sql_fields(),
- ),
+ 'select' => join(', ', @select),
'hashref' => {},
- 'extra_sql' => "$search ORDER BY $orderby",
- 'addl_from' => 'LEFT JOIN cust_main USING ( custnum )',
+ 'extra_sql' => "$search $group_by ORDER BY $orderby",
+ 'addl_from' => $addl_from,
};
} else {
@@ -331,6 +431,7 @@ if ( $cgi->param('magic') ) {
$count_query = "SELECT COUNT(*), SUM($amount_field) FROM $table".
" WHERE payinfo = '$payinfo' AND payby = '$payby'".
" AND ". $curuser->agentnums_sql;
+ @count_addl = ( '$%.2f total '.$opt{name_verb} );
$sql_query = {
'table' => $table,
@@ -342,4 +443,7 @@ if ( $cgi->param('magic') ) {
}
+# for consistency
+$title = join('',map {ucfirst} split(/\b/,$title));
+
</%init>
diff --git a/httemplate/search/elements/report_cust_pay_or_refund.html b/httemplate/search/elements/report_cust_pay_or_refund.html
new file mode 100644
index 0000000..9af4e33
--- /dev/null
+++ b/httemplate/search/elements/report_cust_pay_or_refund.html
@@ -0,0 +1,149 @@
+<%doc>
+
+Examples:
+
+ include( 'elements/report_cust_pay_or_refund.html',
+ 'thing' => 'pay',
+ 'name_singular' => 'payment',
+ )
+
+ include( 'elements/report_cust_pay_or_refund.html',
+ 'thing' => 'refund',
+ 'name_singular' => 'refund',
+ )
+
+</%doc>
+<% include('/elements/header.html', $title ) %>
+
+<FORM ACTION="<% $table %>.html" METHOD="GET">
+<INPUT TYPE="hidden" NAME="magic" VALUE="_date">
+<INPUT TYPE="hidden" NAME="unapplied" VALUE="<% $unapplied %>">
+
+<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+ <TR>
+ <TH CLASS="background" COLSPAN=2 ALIGN="left">
+ <FONT SIZE="+1">Search options</FONT>
+ </TH>
+ </TR>
+
+ <TR>
+ <TD ALIGN="right"><% ucfirst(PL($name_singular)) %> of type: </TD>
+ <TD>
+ <SELECT NAME="payby" onChange="payby_changed(this)">
+ <OPTION VALUE="">all</OPTION>
+ <OPTION VALUE="CARD">credit card (all)</OPTION>
+ <OPTION VALUE="CARD-VisaMC">credit card (Visa/MasterCard)</OPTION>
+ <OPTION VALUE="CARD-Amex">credit card (American Express)</OPTION>
+ <OPTION VALUE="CARD-Discover">credit card (Discover)</OPTION>
+ <OPTION VALUE="CARD-Maestro">credit card (Maestro/Switch/Solo)</OPTION>
+ <OPTION VALUE="CHEK">electronic check / ACH</OPTION>
+ <OPTION VALUE="BILL">check</OPTION>
+ <OPTION VALUE="PREP">prepaid card</OPTION>
+ <OPTION VALUE="CASH">cash</OPTION>
+ <OPTION VALUE="WEST">Western Union</OPTION>
+ <OPTION VALUE="MCRD">manual credit card</OPTION>
+ </SELECT>
+ </TD>
+ </TR>
+
+ <SCRIPT TYPE="text/javascript">
+
+ function payby_changed(what) {
+ if ( what.options[what.selectedIndex].value == 'BILL' ) {
+ document.getElementById('checkno_caption').style.color = '#000000';
+ what.form.payinfo.disabled = false;
+ what.form.payinfo.style.backgroundColor = '#ffffff';
+ } else {
+ document.getElementById('checkno_caption').style.color = '#bbbbbb';
+ what.form.payinfo.disabled = true;
+ what.form.payinfo.style.backgroundColor = '#dddddd';
+ }
+ }
+
+ </SCRIPT>
+
+ <TR>
+ <TD ALIGN="right"><FONT ID="checkno_caption" COLOR="#bbbbbb">Check #: </FONT></TD>
+ <TD>
+ <INPUT TYPE="text" NAME="payinfo" DISABLED STYLE="background-color: #dddddd">
+ </TD>
+ </TR>
+
+ <% include( '/elements/tr-select-agent.html',
+ 'curr_value' => scalar($cgi->param('agentnum')),
+ 'label' => 'for agent: ',
+ 'disable_empty' => 0,
+ )
+ %>
+
+ <% include( '/elements/tr-select-user.html' ) %>
+
+ <TR>
+ <TD ALIGN="right" VALIGN="center">Payment</TD>
+ <TD>
+ <TABLE>
+ <% include( '/elements/tr-input-beginning_ending.html',
+ layout => 'horiz',
+ )
+ %>
+ </TABLE>
+ </TD>
+ </TR>
+
+% if ( $void ) {
+ <TR>
+ <TD ALIGN="right" VALIGN="center">Voided</TD>
+ <TD>
+ <TABLE>
+ <% include( '/elements/tr-input-beginning_ending.html',
+ prefix => 'void',
+ layout => 'horiz',
+ )
+ %>
+ </TABLE>
+ </TD>
+ </TR>
+% }
+
+ <% include( '/elements/tr-input-lessthan_greaterthan.html',
+ 'label' => 'Amount',
+ 'field' => 'paid',
+ )
+ %>
+
+% if ( $table eq 'cust_pay' ) {
+ <% include( '/elements/tr-checkbox.html',
+ 'label' => 'Include tax names',
+ 'field' => 'tax_names',
+ 'value' => 1,
+ )
+ %>
+% }
+
+</TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Get Report">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+<%init>
+
+my %opt = @_;
+my $table = 'cust_'.$opt{'thing'};
+my $name_singular = $opt{'name_singular'};
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
+
+my $void = $cgi->param('void') ? 1 : 0;
+my $unapplied = $cgi->param('unapplied') ? 1 : 0;
+
+my $title = $void ? "Voided $name_singular report" :
+ $unapplied ? "Unapplied $name_singular report" :
+ "\u$name_singular report" ;
+$table .= '_void' if $void;
+
+</%init>
diff --git a/httemplate/search/elements/search-xml.html b/httemplate/search/elements/search-xml.html
index 9f5e9b6..50b1916 100644
--- a/httemplate/search/elements/search-xml.html
+++ b/httemplate/search/elements/search-xml.html
@@ -14,6 +14,7 @@
% } else {
% $value = $row->$field();
% }
+% next unless ($value || !$opt{xml_omit_empty});
%
<% &{$beginfield}($row, $i) %><% $value |h %><% &{$endfield}($row, $i) %>
%
diff --git a/httemplate/search/report_477.html b/httemplate/search/report_477.html
index bc2a958..7ac497a 100755
--- a/httemplate/search/report_477.html
+++ b/httemplate/search/report_477.html
@@ -181,7 +181,7 @@
<% include( '/elements/tr-checkbox.html',
'label' => 'Enable part VI?',
'field' => 'part',
- 'value' => 'VI',
+ 'value' => 'VI_census',
)
%>
diff --git a/httemplate/search/report_cdr.html b/httemplate/search/report_cdr.html
index a50e4db..866606c 100644
--- a/httemplate/search/report_cdr.html
+++ b/httemplate/search/report_cdr.html
@@ -65,7 +65,21 @@
<TR>
<TD ALIGN="right">Charged Party #: </TD>
<TD>
- <INPUT TYPE="text" NAME="charged_party">
+ <INPUT TYPE="text" NAME="charged_party" VALUE="<% join(',', @charged_party) |h %>">
+ </TD>
+ </TR>
+
+ <TR>
+ <TD ALIGN="right">Charged Party or Source #: </TD>
+ <TD>
+ <INPUT TYPE="text" NAME="charged_party_or_src" VALUE="<% join(',', @charged_party_or_src ) |h %>" >
+ </TD>
+ </TR>
+
+ <TR>
+ <TD ALIGN="right">Freeside service #: </TD>
+ <TD>
+ <INPUT TYPE="text" NAME="svcnum" VALUE="<% join(',', @svcnum ) %>" >
</TD>
</TR>
@@ -145,4 +159,72 @@ my $names_list = [ map {
@fields
];
+my @charged_party = ();
+my @charged_party_or_src = ();
+my @svcnum = ();
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+ my $custnum = $1;
+
+ my $cust_main = qsearchs( {
+ 'table' => 'cust_main',
+ 'hashref' => { 'custnum' => $custnum },
+ 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+ });
+ die "Customer not found!" unless $cust_main;
+
+ #historical?
+ foreach my $cust_pkg ( $cust_main->ncancelled_pkgs ) {
+
+ my @voip_pkgs =
+ grep { $_->plan eq 'voip_cdr' } $cust_pkg->part_pkg->self_and_bill_linked;
+ if ( scalar(@voip_pkgs) > 1 ) {
+ die "multiple voip_cdr packages bundled\n";
+ } elsif ( !@voip_pkgs ) {
+ next;
+ }
+ my $voip_pkg = @voip_pkgs[0];
+
+ my $cdr_svc_method = $voip_pkg->option('cdr_svc_method')
+ || 'svc_phone.phonenum';
+
+ my @cust_svc = $cust_pkg->cust_svc; #historical?
+
+ if ( $cdr_svc_method eq 'svc_phone.phonenum' ) {
+
+ my @svc_phone = map $_->svc_x,
+ grep { $_->part_svc->svcdb eq 'svc_phone' } @cust_svc;
+
+ my @numbers = map {
+ my $number = $_->phonenum;
+ $number = $_->countrycode. $number
+ unless $_->countrycode eq '1';
+ $number;
+ }
+ @svc_phone;
+
+ if ( $voip_pkg->option('disable_src') ) {
+ push @charged_party, @numbers;
+ } else {
+ push @charged_party_or_src, @numbers;
+ }
+
+ } elsif ( $cdr_svc_method eq 'svc_pbx.title' ) {
+ my @svc_pbx = map $_->svc_x,
+ grep { $_->part_svc->svcdb eq 'svc_pbx' } @cust_svc;
+ push @charged_party, map $_->title, @svc_pbx;
+ } elsif ( $cdr_svc_method eq 'svc_pbx.svcnum' ) {
+ my @cust_svc_pbx = grep { $_->part_svc->svcdb eq 'svc_pbx' } @cust_svc;
+ push @svcnum, map $_->svcnum, @cust_svc_pbx;
+ }
+
+ }
+
+ die "No CDR packages for customer $custnum\n"
+ unless @charged_party || @charged_party_or_src || @svcnum;
+
+ #die "Multiple matching metods for customer $custnum\n"
+ # if #there's more than one
+
+}
+
</%init>
diff --git a/httemplate/search/report_cust_bill.html b/httemplate/search/report_cust_bill.html
index 00d566a..b1a252e 100644
--- a/httemplate/search/report_cust_bill.html
+++ b/httemplate/search/report_cust_bill.html
@@ -25,6 +25,13 @@
field => 'owed',
)
%>
+ <% include( '/elements/tr-select-payby.html',
+ label => 'Payment method:',
+ payby_type => 'cust',
+ multiple => 1,
+ all_selected => 1,
+ )
+ %>
<TR>
<TD ALIGN="right"><INPUT TYPE="checkbox" NAME="open" VALUE="1" CHECKED></TD>
diff --git a/httemplate/search/report_cust_bill_pkg_discount.html b/httemplate/search/report_cust_bill_pkg_discount.html
index f1879d4..f9ab901 100644
--- a/httemplate/search/report_cust_bill_pkg_discount.html
+++ b/httemplate/search/report_cust_bill_pkg_discount.html
@@ -5,9 +5,9 @@
<TABLE>
- <% include( '/elements/tr-select-otaker.html',
- 'label' => 'Discounts by employee: ',
- 'otakers' => \@otakers,
+ <% include( '/elements/tr-select-user.html',
+ 'label' => 'Discounts by employee: ',
+ 'access_user' => \%access_user,
)
%>
@@ -39,9 +39,12 @@
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
-my $sth = dbh->prepare("SELECT DISTINCT otaker FROM cust_pkg_discount")
+my $sth = dbh->prepare("SELECT DISTINCT usernum FROM cust_pkg_discount")
or die dbh->errstr;
$sth->execute or die $sth->errstr;
-my @otakers = map { $_->[0] } @{$sth->fetchall_arrayref};
+my @usernum = map $_->[0], @{$sth->fetchall_arrayref};
+my %access_user =
+ map { $_ => qsearchs('access_user',{'usernum'=>$_})->username }
+ @usernum;
</%init>
diff --git a/httemplate/search/report_cust_credit.html b/httemplate/search/report_cust_credit.html
index 9c719b7..16a75eb 100644
--- a/httemplate/search/report_cust_credit.html
+++ b/httemplate/search/report_cust_credit.html
@@ -1,13 +1,14 @@
-<% include('/elements/header.html', 'Credit report' ) %>
+<% include('/elements/header.html', $title ) %>
<FORM ACTION="cust_credit.html" METHOD="GET">
<INPUT TYPE="hidden" NAME="magic" VALUE="_date">
+<INPUT TYPE="hidden" NAME="unapplied" VALUE="<% $unapplied %>">
<TABLE>
- <% include( '/elements/tr-select-otaker.html',
- 'label' => 'Credits by employee: ',
- 'otakers' => \@otakers,
+ <% include( '/elements/tr-select-user.html',
+ 'label' => 'Credits by employee: ',
+ 'access_user' => \%access_user,
)
%>
@@ -40,9 +41,17 @@
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
-my $sth = dbh->prepare("SELECT DISTINCT otaker FROM cust_credit")
+my $sth = dbh->prepare("SELECT DISTINCT usernum FROM cust_credit")
or die dbh->errstr;
$sth->execute or die $sth->errstr;
-my @otakers = map { $_->[0] } @{$sth->fetchall_arrayref};
+my @usernum = map $_->[0], @{$sth->fetchall_arrayref};
+my %access_user =
+ map { $_ => qsearchs('access_user',{'usernum'=>$_})->username }
+ @usernum;
+
+my $unapplied = $cgi->param('unapplied') ? 1 : 0;
+
+my $title = $cgi->param('unapplied') ?
+ 'Unapplied credit report' : 'Credit report';
</%init>
diff --git a/httemplate/search/report_cust_main.html b/httemplate/search/report_cust_main.html
index eb1a662..d6be4fb 100755
--- a/httemplate/search/report_cust_main.html
+++ b/httemplate/search/report_cust_main.html
@@ -28,6 +28,11 @@
)
%>
+ <TR>
+ <TD ALIGN="right" VALIGN="center">Address</TD>
+ <TD><INPUT TYPE="text" NAME="address" SIZE=54></TD>
+ </TR>
+
% foreach my $field (qw( signupdate )) {
<TR>
@@ -45,6 +50,14 @@
% }
+ <% include( '/elements/tr-select-cust_tag.html',
+ 'cgi' => $cgi,
+ 'is_report' => 1,
+ 'multiple' => 1,
+ 'all_selected' => 1,
+ )
+ %>
+
<% include( '/elements/tr-select-payby.html',
'payby_type' => 'cust',
'multiple' => 1,
@@ -105,13 +118,18 @@
<TD><INPUT TYPE="checkbox" NAME="cancelled_pkgs"></TD>
</TR>
-% if ( $conf->exists('cust_main-require_censustract') ) {
-
<TR>
<TD ALIGN="right" VALIGN="center">Without census tract</TD>
<TD><INPUT TYPE="checkbox" NAME="no_censustract"></TD>
</TR>
+% if ( $conf->exists('enable_taxproducts') ) {
+
+ <TR>
+ <TD ALIGN="right" VALIGN="center">With hardcoded tax location</TD>
+ <TD><INPUT TYPE="checkbox" NAME="with_geocode"></TD>
+ </TR>
+
% }
<TR>
diff --git a/httemplate/search/report_cust_pay.html b/httemplate/search/report_cust_pay.html
index 6c10a2e..ea7a215 100644
--- a/httemplate/search/report_cust_pay.html
+++ b/httemplate/search/report_cust_pay.html
@@ -1,116 +1,5 @@
-<% include('/elements/header.html', $title ) %>
-
-<FORM ACTION="<% $void ? 'cust_pay_void.html' : 'cust_pay.cgi' %>" METHOD="GET">
-<INPUT TYPE="hidden" NAME="magic" VALUE="_date">
-
-<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
-
- <TR>
- <TH CLASS="background" COLSPAN=2 ALIGN="left">
- <FONT SIZE="+1">Search options</FONT>
- </TH>
- </TR>
-
- <TR>
- <TD ALIGN="right">Payments of type: </TD>
- <TD>
- <SELECT NAME="payby" onChange="payby_changed(this)">
- <OPTION VALUE="">all</OPTION>
- <OPTION VALUE="CARD">credit card (all)</OPTION>
- <OPTION VALUE="CARD-VisaMC">credit card (Visa/MasterCard)</OPTION>
- <OPTION VALUE="CARD-Amex">credit card (American Express)</OPTION>
- <OPTION VALUE="CARD-Discover">credit card (Discover)</OPTION>
- <OPTION VALUE="CARD-Maestro">credit card (Maestro/Switch/Solo)</OPTION>
- <OPTION VALUE="CHEK">electronic check / ACH</OPTION>
- <OPTION VALUE="BILL">check</OPTION>
- <OPTION VALUE="PREP">prepaid card</OPTION>
- <OPTION VALUE="CASH">cash</OPTION>
- <OPTION VALUE="WEST">Western Union</OPTION>
- <OPTION VALUE="MCRD">manual credit card</OPTION>
- </SELECT>
- </TD>
- </TR>
-
- <SCRIPT TYPE="text/javascript">
-
- function payby_changed(what) {
- if ( what.options[what.selectedIndex].value == 'BILL' ) {
- document.getElementById('checkno_caption').style.color = '#000000';
- what.form.payinfo.disabled = false;
- what.form.payinfo.style.backgroundColor = '#ffffff';
- } else {
- document.getElementById('checkno_caption').style.color = '#bbbbbb';
- what.form.payinfo.disabled = true;
- what.form.payinfo.style.backgroundColor = '#dddddd';
- }
- }
-
- </SCRIPT>
-
- <TR>
- <TD ALIGN="right"><FONT ID="checkno_caption" COLOR="#bbbbbb">Check #: </FONT></TD>
- <TD>
- <INPUT TYPE="text" NAME="payinfo" DISABLED STYLE="background-color: #dddddd">
- </TD>
- </TR>
-
- <% include( '/elements/tr-select-agent.html',
- 'curr_value' => scalar($cgi->param('agentnum')),
- 'label' => 'for agent: ',
- 'disable_empty' => 0,
- )
- %>
-
- <% include( '/elements/tr-select-otaker.html' ) %>
-
- <TR>
- <TD ALIGN="right" VALIGN="center">Payment</TD>
- <TD>
- <TABLE>
- <% include( '/elements/tr-input-beginning_ending.html',
- layout => 'horiz',
- )
- %>
- </TABLE>
- </TD>
- </TR>
-
-% if ( $void ) {
- <TR>
- <TD ALIGN="right" VALIGN="center">Voided</TD>
- <TD>
- <TABLE>
- <% include( '/elements/tr-input-beginning_ending.html',
- prefix => 'void',
- layout => 'horiz',
- )
- %>
- </TABLE>
- </TD>
- </TR>
-% }
-
- <% include( '/elements/tr-input-lessthan_greaterthan.html',
- 'label' => 'Amount',
- 'field' => 'paid',
- )
- %>
-
-</TABLE>
-
-<BR>
-<INPUT TYPE="submit" VALUE="Get Report">
-
-</FORM>
-
-<% include('/elements/footer.html') %>
-<%init>
-
-die "access denied"
- unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
-
-my $void = $cgi->param('void') ? 1 : 0;
-
-my $title = $void ? 'Voided payment report' : 'Payment report';
-
-</%init>
+<% include( 'elements/report_cust_pay_or_refund.html',
+ 'thing' => 'pay',
+ 'name_singular' => 'payment',
+ )
+%>
diff --git a/httemplate/search/report_cust_pkg.html b/httemplate/search/report_cust_pkg.html
index 58fcf61..289fec4 100755
--- a/httemplate/search/report_cust_pkg.html
+++ b/httemplate/search/report_cust_pkg.html
@@ -89,7 +89,7 @@
% }
-% foreach my $field (qw( setup last_bill bill adjourn susp expire cancel )) {
+% foreach my $field (qw( setup last_bill bill adjourn susp expire contract_end cancel )) {
<TR>
<TD ALIGN="right" VALIGN="center"><% $label{$field} %></TD>
@@ -181,6 +181,7 @@ my %label = (
'adjourn' => 'Adjourns',
'susp' => 'Suspended',
'expire' => 'Expires',
+ 'contract_end' => 'Contract ends',
'cancel' => 'Cancelled',
);
diff --git a/httemplate/search/report_cust_pkg_discount.html b/httemplate/search/report_cust_pkg_discount.html
index 7ebd44f..31774c3 100644
--- a/httemplate/search/report_cust_pkg_discount.html
+++ b/httemplate/search/report_cust_pkg_discount.html
@@ -16,9 +16,9 @@
</TD>
</TR>
- <% include( '/elements/tr-select-otaker.html',
- 'label' => 'Discounts by employee: ',
- 'otakers' => \@otakers,
+ <% include( '/elements/tr-select-user.html',
+ 'label' => 'Discounts by employee: ',
+ 'access_user' => \%access_user,
)
%>
@@ -42,9 +42,12 @@
die "access denied"
unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
-my $sth = dbh->prepare("SELECT DISTINCT otaker FROM cust_pkg_discount")
+my $sth = dbh->prepare("SELECT DISTINCT usernum FROM cust_pkg_discount")
or die dbh->errstr;
$sth->execute or die $sth->errstr;
-my @otakers = map { $_->[0] } @{$sth->fetchall_arrayref};
+my @usernum = map $_->[0], @{$sth->fetchall_arrayref};
+my %access_user =
+ map { $_ => qsearchs('access_user',{'usernum'=>$_})->username }
+ @usernum;
</%init>
diff --git a/httemplate/search/report_cust_refund.html b/httemplate/search/report_cust_refund.html
index 4d31100..b886f2e 100644
--- a/httemplate/search/report_cust_refund.html
+++ b/httemplate/search/report_cust_refund.html
@@ -1,116 +1,5 @@
-<% include('/elements/header.html', $title ) %>
-
-<FORM ACTION="<% $void ? 'cust_refund_void.html' : 'cust_refund.html' %>" METHOD="GET">
-<INPUT TYPE="hidden" NAME="magic" VALUE="_date">
-
-<TABLE BGCOLOR="#cccccc" CELLSPACING=0>
-
- <TR>
- <TH CLASS="background" COLSPAN=2 ALIGN="left">
- <FONT SIZE="+1">Search options</FONT>
- </TH>
- </TR>
-
- <TR>
- <TD ALIGN="right">Refunds of type: </TD>
- <TD>
- <SELECT NAME="payby" onChange="payby_changed(this)">
- <OPTION VALUE="">all</OPTION>
- <OPTION VALUE="CARD">credit card (all)</OPTION>
- <OPTION VALUE="CARD-VisaMC">credit card (Visa/MasterCard)</OPTION>
- <OPTION VALUE="CARD-Amex">credit card (American Express)</OPTION>
- <OPTION VALUE="CARD-Discover">credit card (Discover)</OPTION>
- <OPTION VALUE="CARD-Maestro">credit card (Maestro/Switch/Solo)</OPTION>
- <OPTION VALUE="CHEK">electronic check / ACH</OPTION>
- <OPTION VALUE="BILL">check</OPTION>
- <OPTION VALUE="PREP">prepaid card</OPTION>
- <OPTION VALUE="CASH">cash</OPTION>
- <OPTION VALUE="WEST">Western Union</OPTION>
- <OPTION VALUE="MCRD">manual credit card</OPTION>
- </SELECT>
- </TD>
- </TR>
-
- <SCRIPT TYPE="text/javascript">
-
- function payby_changed(what) {
- if ( what.options[what.selectedIndex].value == 'BILL' ) {
- document.getElementById('checkno_caption').style.color = '#000000';
- what.form.payinfo.disabled = false;
- what.form.payinfo.style.backgroundColor = '#ffffff';
- } else {
- document.getElementById('checkno_caption').style.color = '#bbbbbb';
- what.form.payinfo.disabled = true;
- what.form.payinfo.style.backgroundColor = '#dddddd';
- }
- }
-
- </SCRIPT>
-
- <TR>
- <TD ALIGN="right"><FONT ID="checkno_caption" COLOR="#bbbbbb">Check #: </FONT></TD>
- <TD>
- <INPUT TYPE="text" NAME="payinfo" DISABLED STYLE="background-color: #dddddd">
- </TD>
- </TR>
-
- <% include( '/elements/tr-select-agent.html',
- 'curr_value' => scalar($cgi->param('agentnum')),
- 'label' => 'for agent: ',
- 'disable_empty' => 0,
- )
- %>
-
- <% include( '/elements/tr-select-otaker.html' ) %>
-
- <TR>
- <TD ALIGN="right" VALIGN="center">Refund</TD>
- <TD>
- <TABLE>
- <% include( '/elements/tr-input-beginning_ending.html',
- layout => 'horiz',
- )
- %>
- </TABLE>
- </TD>
- </TR>
-
-% if ( $void ) {
- <TR>
- <TD ALIGN="right" VALIGN="center">Voided</TD>
- <TD>
- <TABLE>
- <% include( '/elements/tr-input-beginning_ending.html',
- prefix => 'void',
- layout => 'horiz',
- )
- %>
- </TABLE>
- </TD>
- </TR>
-% }
-
- <% include( '/elements/tr-input-lessthan_greaterthan.html',
- 'label' => 'Amount',
- 'field' => 'paid',
- )
- %>
-
-</TABLE>
-
-<BR>
-<INPUT TYPE="submit" VALUE="Get Report">
-
-</FORM>
-
-<% include('/elements/footer.html') %>
-<%init>
-
-die "access denied"
- unless $FS::CurrentUser::CurrentUser->access_right('Financial reports');
-
-my $void = $cgi->param('void') ? 1 : 0;
-
-my $title = $void ? 'Voided refund report' : 'Refund report';
-
-</%init>
+<% include( 'elements/report_cust_pay_or_refund.html',
+ 'thing' => 'refund',
+ 'name_singular' => 'refund',
+ )
+%>
diff --git a/httemplate/search/report_h_cust_pay.html b/httemplate/search/report_h_cust_pay.html
index fe7c4a9..5c7f27a 100644
--- a/httemplate/search/report_h_cust_pay.html
+++ b/httemplate/search/report_h_cust_pay.html
@@ -88,7 +88,7 @@
)
%>
- <% include( '/elements/tr-select-otaker.html' ) %>
+ <% include( '/elements/tr-select-user.html' ) %>
<TR>
<TD ALIGN="right" VALIGN="center">Payment</TD>
diff --git a/httemplate/search/report_receivables.html b/httemplate/search/report_receivables.html
index 912ef26..e85d786 100755
--- a/httemplate/search/report_receivables.html
+++ b/httemplate/search/report_receivables.html
@@ -20,8 +20,23 @@
<TR>
<TD ALIGN="right">Customers</TD>
<TD>
- <INPUT TYPE="radio" NAME="all_customers" VALUE="1" onClick="if (this.checked) { document.OneTrueForm.days.disabled=true; document.OneTrueForm.days.style.backgroundColor = '#dddddd'; } else { document.OneTrueForm.days.disabled=false; document.OneTrueForm.days.style.backgroundColor = '#ffffff'; }">All customers (even those without an outstanding balance)<BR>
- <INPUT TYPE="radio" NAME="all_customers" VALUE="0" CHECKED onClick="if ( ! this.checked ) { document.OneTrueForm.days.disabled=true; document.OneTrueForm.days.style.backgroundColor = '#dddddd'; } else { document.OneTrueForm.days.disabled=false; document.OneTrueForm.days.style.backgroundColor = '#ffffff'; }">Customers with a balance over <INPUT NAME="days" TYPE="text" SIZE=4 MAXLENGTH=3 VALUE="0"> days old
+ <SCRIPT TYPE="text/javascript">
+function toggle(obj) {
+ var f = document.OneTrueForm;
+ var val = (obj.value == obj.checked);
+ f.days.disabled = val;
+ f.negative.disabled = val;
+ f.days.style.backgroundColor = val ? '#dddddd' : '#ffffff';
+}
+ </SCRIPT>
+ <TABLE STYLE="padding: 0px">
+ <TR><TD><INPUT TYPE="radio" NAME="all_customers" VALUE="1" onClick="toggle(this)"></TD>
+ <TD>All customers (even those without an outstanding balance)</TD></TR>
+ <TR><TD><INPUT TYPE="radio" NAME="all_customers" VALUE="0" CHECKED onClick="toggle(this)"></TD>
+ <TD>Customers with a balance over <INPUT NAME="days" TYPE="text" SIZE=4 MAXLENGTH=3 VALUE="0"> days old</TD></TR>
+ <TR><TD></TD>
+ <TD><INPUT TYPE="checkbox" NAME="negative" VALUE="1">&nbsp;Including customers with credit balances</TD></TR>
+ </TABLE>
</TD>
</TR>
<% include( '/elements/tr-input-date-field.html', {
diff --git a/httemplate/search/report_svc_broadband.html b/httemplate/search/report_svc_broadband.html
new file mode 100755
index 0000000..8571ef1
--- /dev/null
+++ b/httemplate/search/report_svc_broadband.html
@@ -0,0 +1,100 @@
+<% include('/elements/header.html', $title ) %>
+
+<FORM ACTION="svc_broadband.cgi" METHOD="GET">
+<INPUT TYPE="hidden" NAME="magic" VALUE="advanced">
+<INPUT TYPE="hidden" NAME="custnum" VALUE="<% $custnum %>">
+%# extensive false laziness with svc_acct
+ <TABLE BGCOLOR="#cccccc" CELLSPACING=0>
+
+ <TR>
+ <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1">Search options</FONT></TH>
+ </TR>
+
+% unless ( $custnum ) {
+ <% include( '/elements/tr-select-agent.html',
+ 'curr_value' => scalar( $cgi->param('agentnum') ),
+ 'disable_empty' => 0,
+ )
+ %>
+
+ <% include( '/elements/tr-select-table.html',
+ 'label' => 'Routers',
+ 'table' => 'router',
+ 'name_col' => 'routername',
+ 'curr_value' => $routernum,
+ 'hashref' => {},
+ 'multiple' => 'multiple',
+ )
+ %>
+% }
+
+ <% include( '/elements/tr-selectmultiple-part_pkg.html',
+ %pkg_search,
+ )
+ %>
+
+ <TR>
+ <TH CLASS="background" COLSPAN=2>&nbsp;</TH>
+ </TR>
+
+ <TR>
+ <TH CLASS="background" COLSPAN=2 ALIGN="left"><FONT SIZE="+1">Display options</FONT></TH>
+ </TR>
+
+% #move to /elements/tr-select-cust_pkg-fields if anything else needs it...
+ <TR>
+ <TD ALIGN="right">Package fields</TD>
+ <TD>
+ <SELECT NAME="cust_pkg_fields">
+ <OPTION VALUE="">(none)
+ <OPTION VALUE="setup,last_bill,bill,cancel">Setup date | Last bill date | Next bill date | Cancel date
+ </SELECT>
+ </TD>
+ </TR>
+
+ <% include( '/elements/tr-select-cust-fields.html' ) %>
+
+ </TABLE>
+
+<BR>
+<INPUT TYPE="submit" VALUE="Get Report">
+
+</FORM>
+
+<% include('/elements/footer.html') %>
+<%init>
+
+die "access denied"
+ unless $FS::CurrentUser::CurrentUser->access_right('List packages'); #?
+
+my $title = 'Broadband Service Report';
+my $routernum = [ $cgi->param('routernum') || '' ];
+$routernum = join(',', @$routernum);
+
+#false laziness w/report_cust_pkg.html
+my $custnum = '';
+if ( $cgi->param('custnum') =~ /^(\d+)$/ ) {
+ $custnum = $1;
+ my $cust_main = qsearchs({
+ 'table' => 'cust_main',
+ 'hashref' => { 'custnum' => $custnum },
+ 'extra_sql' => ' AND '. $FS::CurrentUser::CurrentUser->agentnums_sql,
+ }) or die "unknown custnum $custnum";
+ $title .= ': '. $cust_main->name;
+}
+
+# exclude one-time charges, disabled packages, and packages with no
+# broadband services
+my %pkg_search = (
+ 'extra_sql' => "
+WHERE freq != '0' AND disabled IS NULL AND 0 < (
+ SELECT COUNT(*) FROM part_svc JOIN pkg_svc USING ( svcpart )
+ WHERE pkg_svc.pkgpart = part_pkg.pkgpart AND part_svc.svcdb = 'svc_broadband'
+ AND pkg_svc.quantity > 0
+)",
+);
+
+</%init>
+<%once>
+
+</%once>
diff --git a/httemplate/search/rt_transaction.html b/httemplate/search/rt_transaction.html
index 8dda4ba..fb828af 100644
--- a/httemplate/search/rt_transaction.html
+++ b/httemplate/search/rt_transaction.html
@@ -3,7 +3,7 @@
'name_singular' => 'transaction',
'query' => $query,
'count_query' => $count_query,
- 'count_addl' => [ $format_seconds_sub, $format_seconds_sub, ],
+ 'count_addl' => [ $format_seconds_sub ],#$format_seconds_sub, ],
'header' => [ 'Ticket #',
'Ticket',
'Date',
@@ -54,9 +54,9 @@ my $transactiontime = "
";
my $join = 'JOIN Tickets ON Transactions.ObjectId = Tickets.Id '.
- 'JOIN Users ON Transactions.Creator = Users.Id '.
- 'LEFT JOIN acct_rt_transaction '.
- ' ON Transactions.Id = acct_rt_transaction.transaction_id';
+ 'JOIN Users ON Transactions.Creator = Users.Id '; #.
+# 'LEFT JOIN acct_rt_transaction '.
+# ' ON Transactions.Id = acct_rt_transaction.transaction_id';
my $where = "
WHERE objecttype='RT::Ticket'
AND ( ( Transactions.Type = 'Set'
@@ -90,20 +90,28 @@ if ( $cgi->param('ticketid') =~ /^\s*(\d+)\s*$/ ) {
}
if ( $cgi->param('svcnum') =~ /^\s*(\d+)\s*$/ ) {
- $where .= " AND acct_rt_transaction.svcnum = $1";
+ $where .= " AND EXISTS( SELECT 1 FROM acct_rt_transaction WHERE acct_rt_transaction.transaction_id = Transactions.id AND svcnum = $1 )";
}
my $query = {
- 'select' => "Transactions.*, Tickets.Id AS ticketid, Tickets.Subject, Users.name as otaker, $transactiontime AS transaction_time, acct_rt_transaction.support",
+ 'select' => join(', ',
+ 'Transactions.*',
+ 'Tickets.Id AS ticketid',
+ 'Tickets.Subject',
+ 'Users.name AS otaker',
+ "$transactiontime AS transaction_time",
+ '( SELECT SUM(support) from acct_rt_transaction where transaction_id = Transactions.id ) AS support',
+ ),
+ 'table' => 'transactions', #Pg-ism
#'table' => 'Transactions',
- 'table' => 'transactions',
'addl_from' => $join,
'extra_sql' => $where,
'order by' => 'ORDER BY Created',
};
my $count_query =
- "SELECT COUNT(*), SUM($transactiontime), SUM(acct_rt_transaction.support) FROM Transactions $join $where";
+ #"SELECT COUNT(*), SUM($transactiontime), SUM(acct_rt_transaction.support) FROM Transactions $join $where";
+ "SELECT COUNT(*), SUM($transactiontime) FROM Transactions $join $where";
my $link = [ "${p}rt/Ticket/Display.html?id=", sub { shift->get('ticketid'); } ];
diff --git a/httemplate/search/svc_acct.cgi b/httemplate/search/svc_acct.cgi
index 1407d9e..c3ddd66 100755
--- a/httemplate/search/svc_acct.cgi
+++ b/httemplate/search/svc_acct.cgi
@@ -262,13 +262,13 @@ if ( $cgi->param('magic') =~ /^(all|unlinked)$/ ) {
}
$cgi->param('username') =~ /^([\w\-\.\&]+)$/; #untaint username_text
- my $username = $1;
+ my $username = lc($1);
- push @username_sql, "username ILIKE '$username'"
+ push @username_sql, "LOWER(username) LIKE '$username'"
if $username_type{'Exact'}
|| $username_type{'Fuzzy'};
- push @username_sql, "username ILIKE '\%$username\%'"
+ push @username_sql, "LOWER(username) LIKE '\%$username\%'"
if $username_type{'Substring'}
|| $username_type{'All'};
diff --git a/httemplate/search/svc_broadband.cgi b/httemplate/search/svc_broadband.cgi
index d0b1029..7026f52 100755
--- a/httemplate/search/svc_broadband.cgi
+++ b/httemplate/search/svc_broadband.cgi
@@ -1,8 +1,9 @@
<% include( 'elements/search.html',
'title' => 'Broadband Search Results',
'name' => 'broadband services',
+ 'html_init' => $html_init,
'query' => $sql_query,
- 'count_query' => $count_query,
+ 'count_query' => $sql_query->{'count_query'},
'redirect' => [ popurl(2). "view/svc_broadband.cgi?", 'svcnum' ],
'header' => [ '#',
'Service',
@@ -43,66 +44,29 @@
%>
<%init>
-die "access denied"
- unless $FS::CurrentUser::CurrentUser->access_right('List services');
+die "access denied" unless
+ $FS::CurrentUser::CurrentUser->access_right('List services');
my $conf = new FS::Conf;
-my $orderby = 'ORDER BY svcnum';
-my %svc_broadband = ();
-my @extra_sql = ();
-if ( $cgi->param('magic') =~ /^(all|unlinked)$/ ) {
-
- push @extra_sql, 'pkgnum IS NULL'
- if $cgi->param('magic') eq 'unlinked';
-
- if ( $cgi->param('sortby') =~ /^(\w+)$/ ) {
- my $sortby = $1;
- $orderby = "ORDER BY $sortby";
+my %search_hash;
+if ( $cgi->param('magic') eq 'unlinked' ) {
+ %search_hash = ( 'unlinked' => 1 );
+}
+else {
+ foreach (qw(custnum agentnum svcpart)) {
+ $search_hash{$_} = $cgi->param($_) if $cgi->param($_);
+ }
+ foreach (qw(pkgpart routernum)) {
+ $search_hash{$_} = [ $cgi->param($_) ] if $cgi->param($_);
}
-
-} elsif ( $cgi->param('svcpart') =~ /^(\d+)$/ ) {
- push @extra_sql, "svcpart = $1";
-} elsif ( $cgi->param('ip_addr') =~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/ ) {
- push @extra_sql, "ip_addr = '$1'";
}
-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 ) ';
-
-push @extra_sql, $FS::CurrentUser::CurrentUser->agentnums_sql(
- 'null_right' => 'View/link unlinked services'
- );
-
-my $extra_sql = '';
-if ( @extra_sql ) {
- $extra_sql = ( keys(%svc_broadband) ? ' AND ' : ' WHERE ' ).
- join(' AND ', @extra_sql );
+if ( $cgi->param('sortby') =~ /^(\w+)$/ ) {
+ $search_hash{'order_by'} = $1;
}
-my $count_query = "SELECT COUNT(*) FROM svc_broadband $addl_from ";
-#if ( keys %svc_broadband ) {
-# $count_query .= ' WHERE '.
-# join(' AND ', map "$_ = ". dbh->quote($svc_broadband{$_}),
-# keys %svc_broadband
-# );
-#}
-$count_query .= $extra_sql;
-
-my $sql_query = {
- 'table' => 'svc_broadband',
- 'hashref' => {}, #\%svc_broadband,
- 'select' => join(', ',
- 'svc_broadband.*',
- 'part_svc.svc',
- 'cust_main.custnum',
- FS::UI::Web::cust_sql_fields(),
- ),
- 'extra_sql' => $extra_sql,
- 'addl_from' => $addl_from,
-};
+my $sql_query = FS::svc_broadband->search(\%search_hash);
my %routerbyblock = ();
foreach my $router (qsearch('router', {})) {
@@ -120,4 +84,9 @@ my $link_router = sub { my $routernum = $routerbyblock{shift->blocknum}->routern
my $link_cust = [ $p.'view/cust_main.cgi?', 'custnum' ];
+my $html_init = include('/elements/email-link.html',
+ 'search_hash' => \%search_hash,
+ 'table' => 'svc_broadband'
+ );
+
</%init>
diff --git a/httemplate/view/cust_bill.cgi b/httemplate/view/cust_bill.cgi
index ce8d96a..0928d04 100755
--- a/httemplate/view/cust_bill.cgi
+++ b/httemplate/view/cust_bill.cgi
@@ -26,7 +26,7 @@
% if ( $cust_bill->owed > 0
% && scalar( grep $payby{$_}, qw(BILL CASH WEST MCRD) )
-% && $curuser->access_right('Post payment')
+% && $curuser->access_right(['Post payment', 'Post check payment', 'Post cash payment'])
% && ! $conf->exists('pkg-balances')
% )
% {
@@ -34,22 +34,22 @@
Post
-% if ( $payby{'BILL'} ) {
+% if ( $payby{'BILL'} && $curuser->access_right(['Post payment', 'Post check payment']) ) {
<% $s++ ? ' | ' : '' %>
<A HREF="<% $p %>edit/cust_pay.cgi?payby=BILL;invnum=<% $invnum %>">check</A>
% }
-% if ( $payby{'CASH'} ) {
+% if ( $payby{'CASH'} && $curuser->access_right(['Post payment', 'Post cash payment']) ) {
<% $s++ ? ' | ' : '' %>
<A HREF="<% $p %>edit/cust_pay.cgi?payby=CASH;invnum=<% $invnum %>">cash</A>
% }
-% if ( $payby{'WEST'} ) {
+% if ( $payby{'WEST'} && $curuser->access_right(['Post payment']) ) {
<% $s++ ? ' | ' : '' %>
<A HREF="<% $p %>edit/cust_pay.cgi?payby=WEST;invnum=<% $invnum %>">Western Union</A>
% }
-% if ( $payby{'MCRD'} ) {
+% if ( $payby{'MCRD'} && $curuser->access_right(['Post payment']) ) {
<% $s++ ? ' | ' : '' %>
<A HREF="<% $p %>edit/cust_pay.cgi?payby=MCRD;invnum=<% $invnum %>">manual credit card</A>
% }
@@ -117,13 +117,14 @@ if ( $query =~ /^((.+)-)?(\d+)$/ ) {
$notice_name = $cgi->param('notice_name');
}
+my $conf = new FS::Conf;
+
my %opt = (
- 'template' => $template,
- 'notice_name' => $notice_name,
+ 'unsquelch_cdr' => $conf->exists('voip-cdr_email'),
+ 'template' => $template,
+ 'notice_name' => $notice_name,
);
-my $conf = new FS::Conf;
-
my @payby = grep /\w/, $conf->config('payby');
#@payby = (qw( CARD DCRD CHEK DCHK LECB BILL CASH WEST COMP ))
@payby = (qw( CARD DCRD CHEK DCHK LECB BILL CASH COMP ))
diff --git a/httemplate/view/cust_main.cgi b/httemplate/view/cust_main.cgi
index f6bef43..0f9c1e2 100755
--- a/httemplate/view/cust_main.cgi
+++ b/httemplate/view/cust_main.cgi
@@ -1,5 +1,5 @@
<% include('/elements/header.html', {
- 'title' => "Customer: ". $cust_main->name,
+ 'title' => $title,
'nobr' => 1,
})
%>
@@ -57,12 +57,29 @@ function areyousure(href, message) {
'color' => '#ff0000',
'cust_main' => $cust_main,
'width' => 616, #make room for reasons
+ 'height' => 366,
}
)
%> |
% }
+% if ( $curuser->access_right('Merge customer') ) {
+
+ <% include( '/elements/popup_link-cust_main.html',
+ { 'action' => $p. 'misc/merge_cust.html',
+ 'label' => 'Merge&nbsp;this&nbsp;customer',
+ 'actionlabel' => 'Merge customer',
+ #'color' => '#ff0000',
+ 'cust_main' => $cust_main,
+ 'width' => 480,
+ 'height' => 192,
+ }
+ )
+ %> |
+
+% }
+
% if ( $conf->exists('deletecustomers')
% && $curuser->access_right('Delete customer')
% ) {
@@ -233,6 +250,10 @@ Comments
<% include('cust_main/change_history.html', $cust_main ) %>
% }
+% if ( $view eq 'custom' ) {
+<% include('cust_main/custom.html', $cust_main ) %>
+% }
+
</DIV>
<% include('/elements/footer.html') %>
<%init>
@@ -261,6 +282,11 @@ my $cust_main = qsearchs( {
});
die "Customer not found!" unless $cust_main;
+my $title = $cust_main->name;
+$title = '('. $cust_main->display_custnum. ") $title"
+ if $conf->exists('cust_main-title-display_custnum');
+$title = "Customer: $title";
+
#false laziness w/pref/pref.html and Conf.pm (cust_main-default_view)
tie my %views, 'Tie::IxHash',
'Basics' => 'basics',
@@ -273,6 +299,8 @@ $views{'Payment History'} = 'payment_history'
unless $conf->config('payby-default' eq 'HIDE');
$views{'Change History'} = 'change_history'
if $curuser->access_right('View customer history');
+$views{$conf->config('cust_main-custom_title') || 'Custom'} = 'custom'
+ if $conf->config('cust_main-custom_link');
$views{'Jumbo'} = 'jumbo';
my %viewname = reverse %views;
diff --git a/httemplate/view/cust_main/billing.html b/httemplate/view/cust_main/billing.html
index 54c180b..014ddab 100644
--- a/httemplate/view/cust_main/billing.html
+++ b/httemplate/view/cust_main/billing.html
@@ -132,7 +132,7 @@ Billing information
<TR>
<TD ALIGN="right">Attention</TD>
- <TD BGCOLOR="#ffffff"><% $cust_main->payname %></TD>
+ <TD BGCOLOR="#ffffff"><% $cust_main->payname |h %></TD>
</TR>
% } elsif ( $cust_main->payby eq 'COMP' ) {
@@ -206,6 +206,14 @@ Billing information
<% $cust_main->invoice_terms || 'Default ('. ( $conf->config('invoice_default_terms') || 'Payable upon receipt' ). ')' %>
</TD>
</TR>
+<TR>
+ <TD ALIGN="right">Credit&nbsp;limit</TD>
+ <TD BGCOLOR="#ffffff">
+ <% length($cust_main->credit_limit) ?
+ $money_char.sprintf("%.2f", $cust_main->credit_limit) :
+ 'Unlimited' %>
+ </TD>
+</TR>
% if ( $conf->exists('voip-cust_cdr_spools') ) {
<TR>
diff --git a/httemplate/view/cust_main/contacts.html b/httemplate/view/cust_main/contacts.html
index e88c02e..e91af54 100644
--- a/httemplate/view/cust_main/contacts.html
+++ b/httemplate/view/cust_main/contacts.html
@@ -10,7 +10,7 @@
<TR>
<TD ALIGN="right">Contact name</TD>
<TD COLSPAN=5 BGCOLOR="#ffffff">
- <% $cust_main->get("${pre}last"). ', '. $cust_main->get("${pre}first") %>
+ <% $cust_main->get("${pre}last"). ', '. $cust_main->get("${pre}first") |h %>
</TD>
% if ( $which eq '' && $conf->exists('show_ss') ) {
<TD ALIGN="right">SS#</TD>
@@ -19,11 +19,11 @@
</TR>
<TR>
<TD ALIGN="right">Company</TD>
- <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}company") %></TD>
+ <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}company") |h %></TD>
</TR>
<TR>
<TD ALIGN="right">Address</TD>
- <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address1") %></TD>
+ <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address1") |h %></TD>
</TR>
% if ( $cust_main->get("${pre}address2") ) {
@@ -36,20 +36,20 @@
<TR>
<TD ALIGN="right"><% $address2_label %></TD>
- <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address2") %></TD>
+ <TD COLSPAN=7 BGCOLOR="#ffffff"><% $cust_main->get("${pre}address2") |h %></TD>
</TR>
% }
<TR>
<TD ALIGN="right">City</TD>
- <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}city") %></TD>
+ <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}city") |h %></TD>
% if ( $cust_main->get("${pre}county") ) {
<TD ALIGN="right">County</TD>
- <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}county") %></TD>
+ <TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}county") |h %></TD>
% }
<TD ALIGN="right">State</TD>
- <TD BGCOLOR="#ffffff"><% state_label( $cust_main->get("${pre}state"), $cust_main->get("${pre}country") ) %></TD>
+ <TD BGCOLOR="#ffffff"><% state_label( $cust_main->get("${pre}state"), $cust_main->get("${pre}country") ) |h %></TD>
<TD ALIGN="right">Zip</TD>
<TD BGCOLOR="#ffffff"><% $cust_main->get("${pre}zip") %></TD>
</TR>
diff --git a/httemplate/view/cust_main/custom.html b/httemplate/view/cust_main/custom.html
new file mode 100644
index 0000000..8e2e07b
--- /dev/null
+++ b/httemplate/view/cust_main/custom.html
@@ -0,0 +1,21 @@
+<IFRAME id="customframe"
+ src="<% $proxyurl %>"
+ onload="resizeFrame(this)"
+ frameborder=0
+ marginheight="0px"
+ marginwidth="0px"
+ width="100%"
+ scrolling="no"
+>
+</IFRAME>
+<SCRIPT TYPE="text/javascript">
+function resizeFrame(f) {
+ f.style.height = f.contentDocument.body.scrollHeight + 'px';
+}
+</SCRIPT>
+<%init>
+
+my( $cust_main ) = @_;
+
+my $proxyurl = $p.'/misc/custom_link_proxy.cgi?custnum='.$cust_main->custnum;
+</%init>
diff --git a/httemplate/view/cust_main/packages.html b/httemplate/view/cust_main/packages.html
index 811ac3c..660d0ef 100755
--- a/httemplate/view/cust_main/packages.html
+++ b/httemplate/view/cust_main/packages.html
@@ -57,7 +57,9 @@ Current packages
<TD ALIGN="right">
<A HREF="<%$p%>search/report_cust_pkg.html?custnum=<% $cust_main->custnum %>">Package reports</A><BR>
Service reports:
- <A HREF="<%$p%>search/report_svc_acct.html?custnum=<% $cust_main->custnum %>">accounts</A>
+ <A HREF="<%$p%>search/report_svc_acct.html?custnum=<% $cust_main->custnum %>">accounts</A><BR>
+ Usage reports:
+ <A HREF="<%$p%>search/report_cdr.html?custnum=<% $cust_main->custnum %>">CDRs</A>
</TD>
</TR>
@@ -161,6 +163,7 @@ my %conf_opt = (
'legacy_link' => $conf->exists('legacy_link'),
'svc_broadband-manage_link' => scalar($conf->config('svc_broadband-manage_link')),
'maestro-status_test' => $conf->exists('maestro-status_test'),
+ 'cust_pkg-large_pkg_size' => $conf->config('cust_pkg-large_pkg_size'),
);
#subroutines
diff --git a/httemplate/view/cust_main/packages/package.html b/httemplate/view/cust_main/packages/package.html
index 3c486dd..3b58f9e 100644
--- a/httemplate/view/cust_main/packages/package.html
+++ b/httemplate/view/cust_main/packages/package.html
@@ -39,6 +39,7 @@
% if ( $curuser->access_right('Discount customer package')
% && $part_pkg->can_discount
% && ! scalar($cust_pkg->cust_pkg_discount_active)
+% && ! scalar($cust_pkg->part_pkg->part_pkg_discount)
% )
% {
% $br=1;
diff --git a/httemplate/view/cust_main/packages/services.html b/httemplate/view/cust_main/packages/services.html
index 6e30922..512efcc 100644
--- a/httemplate/view/cust_main/packages/services.html
+++ b/httemplate/view/cust_main/packages/services.html
@@ -4,12 +4,40 @@
<TD CLASS="inv" BGCOLOR="<% $bgcolor %>">
<TABLE CLASS="inv" BORDER=0 CELLSPACING=0 CELLPADDING=0 WIDTH="100%">
+ <SCRIPT TYPE="text/javascript">
+function clearhint_search_cust_svc(obj, str) {
+ if (obj.value == str) obj.value = '';
+}
+ </SCRIPT>
% #foreach my $svcpart (sort {$a->{svcpart} <=> $b->{svcpart}} @{$pkg->{svcparts}}) {
% foreach my $part_svc ( $cust_pkg->part_svc ) {
-% #foreach my $service (@{$svcpart->{services}}) {
-% foreach my $cust_svc ( @{ $part_svc->cust_pkg_svc } ) {
+% if ( $opt{'cust_pkg-large_pkg_size'} > 0 and
+% $opt{'cust_pkg-large_pkg_size'} <= $cust_pkg->num_svcs ) {
+% # summarize
+ <TR>
+ <TD ALIGN="center" VALIGN="top">
+% my $href="${p}search/cust_pkg_svc.html?svcpart=".$part_svc->svcpart.
+% ";pkgnum=".$cust_pkg->pkgnum;
+ <A HREF="<% $href %>"><% $part_svc->svc %></A>&nbsp;
+ <A HREF="<% $href %>"><B>(view all <% $cust_pkg->num_svcs %>)</B></A>
+% my $hint = $hints{$part_svc->svcdb};
+% if ( $hint ) {
+ <BR>
+ <FORM name="svcpart<%$part_svc->svcpart%>_search" STYLE="display:inline"
+ ACTION="<%$p%>search/cust_pkg_svc.html" METHOD="GET">
+ <INPUT TYPE="hidden" NAME="svcpart" VALUE="<%$part_svc->svcpart%>">
+ <INPUT TYPE="hidden" NAME="pkgnum" VALUE="<%$cust_pkg->pkgnum%>">
+ <INPUT TYPE="text" NAME="search_svc"
+ onfocus="clearhint_search_cust_svc(this, '<%$hint%>')" VALUE="<%$hint%>">
+ <INPUT TYPE="submit" VALUE="Search"></FORM>
+% } #$hint
+ </TD>
+ </TR>
+% }
+% else {
+% foreach my $cust_svc ( @{ $part_svc->cust_pkg_svc } ) {
<TR>
<TD ALIGN="right" VALIGN="top"><% FS::UI::Web::svc_link($m, $part_svc, $cust_svc) %></TD>
@@ -65,7 +93,8 @@
</TD>
</TR>
-% }
+% } #foreach $cust_svc
+% }
% if ( ! $cust_pkg->get('cancel')
% && $curuser->access_right('Provision customer service')
@@ -137,4 +166,13 @@ sub svc_unprovision_link {
qq!', 'Permanently unprovision and delete this service?')">Unprovision</A>!;
}
+my %hints = (
+svc_acct => '(user or email)',
+svc_domain => '(domain)',
+svc_broadband => '(ip or mac)',
+svc_forward => '(email)',
+svc_phone => '(phone)',
+svc_pbx => '(phone)',
+);
+
</%init>
diff --git a/httemplate/view/cust_main/packages/status.html b/httemplate/view/cust_main/packages/status.html
index a686843..c05cd5a 100644
--- a/httemplate/view/cust_main/packages/status.html
+++ b/httemplate/view/cust_main/packages/status.html
@@ -54,8 +54,11 @@
<% pkg_status_row_changed( $cust_pkg, %opt, 'colspan'=>$colspan ) %>
<% pkg_status_row_if( $cust_pkg, $last_bill_or_renewed, 'last_bill', %opt, curuser=>$curuser ) %>
-% # pkg_status_row($cust_pkg, 'Next bill', 'bill', %opt)
+% if ( $part_pkg->option('suspend_bill') ) {
+ <% pkg_status_row_if( $cust_pkg, 'Next&nbsp;bill', 'bill', %opt, curuser=>$curuser ) %>
+% }
<% pkg_status_row_if( $cust_pkg, 'Expires', 'expire', %opt, curuser=>$curuser ) %>
+ <% pkg_status_row_if( $cust_pkg, 'Contract ends', 'contract_end', %opt ) %>
<TR>
<TD COLSPAN=<%$colspan%>>
@@ -167,6 +170,7 @@
<% pkg_status_row_if($cust_pkg, 'Will automatically suspend by', 'autosuspend', %opt) %>
<% pkg_status_row_if( $cust_pkg, 'Will suspend on', 'adjourn', %opt, curuser=>$curuser ) %>
<% pkg_status_row_if( $cust_pkg, 'Expires', 'expire', %opt, curuser=>$curuser ) %>
+ <% pkg_status_row_if( $cust_pkg, 'Contract ends', 'contract_end', %opt ) %>
% if ( $part_pkg->freq ) {
diff --git a/httemplate/view/cust_main/payment_history.html b/httemplate/view/cust_main/payment_history.html
index 0dc4c41..046899e 100644
--- a/httemplate/view/cust_main/payment_history.html
+++ b/httemplate/view/cust_main/payment_history.html
@@ -1,7 +1,7 @@
%# payment links
% my $s = 0;
-% if ( $payby{'BILL'} && $curuser->access_right('Post payment') ) {
+% if ( $payby{'BILL'} && $curuser->access_right(['Post payment', 'Post check payment' ]) ) {
<% $s++ ? ' | ' : '' %>
<% include('/elements/popup_link-cust_main.html',
'label' => 'Enter check payment',
@@ -14,7 +14,7 @@
%>
% }
-% if ( $payby{'CASH'} && $curuser->access_right('Post payment') ) {
+% if ( $payby{'CASH'} && $curuser->access_right(['Post payment', 'Post cash payment']) ) {
<% $s++ ? ' | ' : '' %>
<% include('/elements/popup_link-cust_main.html',
'label' => 'Enter cash payment',
@@ -33,7 +33,7 @@
% }
% if ( ( $payby{'CARD'} || $payby{'DCRD'} )
-% && $curuser->access_right('Process payment')
+% && $curuser->access_right(['Process payment', 'Process credit card payment'])
% && ! $cust_main->is_encrypted($cust_main->payinfo)
% ) {
<% $s++ ? ' | ' : '' %>
@@ -41,7 +41,7 @@
% }
% if ( ( $payby{'CHEK'} || $payby{'DCHK'} )
-% && $curuser->access_right('Process payment')
+% && $curuser->access_right(['Process payment', 'Process Echeck payment'])
% && ! $cust_main->is_encrypted($cust_main->payinfo)
% ) {
<% $s++ ? ' | ' : '' %>
@@ -73,7 +73,7 @@
%# refund links
% $s = 0;
-% if ( $payby{'BILL'} && $curuser->access_right('Post refund') ) {
+% if ( $payby{'BILL'} && $curuser->access_right(['Post refund', 'Post check refund']) ) {
<% $s++ ? ' | ' : '' %>
<% include('/elements/popup_link-cust_main.html',
'label' => 'Enter check refund',
@@ -86,7 +86,7 @@
%>
% }
-% if ( $payby{'CASH'} && $curuser->access_right('Post refund') ) {
+% if ( $payby{'CASH'} && $curuser->access_right(['Post refund', 'Post cash refund']) ) {
<% $s++ ? ' | ' : '' %>
<% include('/elements/popup_link-cust_main.html',
'label' => 'Enter cash refund',
@@ -291,7 +291,7 @@
<TR <% $display ? $display.' ID="old_history'.$old_history++.'"' : ''%>>
- <TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
+ <TD VALIGN="top" CLASS="grid" BGCOLOR="<% $bgcolor %>">
% unless ( !$target || $target{$target}++ ) {
<A NAME="<% $target %>">
@@ -308,19 +308,19 @@
<TD CLASS="grid" BGCOLOR="<% $bgcolor %>">
<% $item->{'desc'} %>
</TD>
- <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
+ <TD VALIGN="top" ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
<% $charge %>
</TD>
- <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
+ <TD VALIGN="top" ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
<% $payment %>
</TD>
- <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
+ <TD VALIGN="top" ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
<% $credit %>
</TD>
- <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
+ <TD VALIGN="top" ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
<% $refund %>
</TD>
- <TD ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
+ <TD VALIGN="top" ALIGN="right" CLASS="grid" BGCOLOR="<% $bgcolor %>">
<% $showbalance %>
</TD>
</TR>
@@ -412,6 +412,16 @@ foreach my $cust_pay ($cust_main->cust_pay) {
};
}
+#pending payments
+foreach my $cust_pay_pending ($cust_main->cust_pay_pending) {
+ push @history, {
+ 'date' => $cust_pay_pending->_date,
+ 'desc' => include('payment_history/pending_payment.html', $cust_pay_pending, %opt ),
+ 'void_payment' => $cust_pay_pending->paid,
+ };
+}
+
+
#voided payments
foreach my $cust_pay_void ($cust_main->cust_pay_void) {
push @history, {
@@ -422,6 +432,16 @@ foreach my $cust_pay_void ($cust_main->cust_pay_void) {
}
+#declined payments
+foreach my $cust_pay_pending ($cust_main->cust_pay_pending_attempt) {
+ push @history, {
+ 'date' => $cust_pay_pending->_date,
+ 'desc' => include('payment_history/attempted_payment.html', $cust_pay_pending, %opt ),
+ 'void_payment' => $cust_pay_pending->paid, #??
+ #'target' => $target, #XXX
+ };
+}
+
#credits (some false laziness w/payments)
foreach my $cust_credit ($cust_main->cust_credit) {
push @history, {
diff --git a/httemplate/view/cust_main/payment_history/attempted_payment.html b/httemplate/view/cust_main/payment_history/attempted_payment.html
new file mode 100644
index 0000000..554aa73
--- /dev/null
+++ b/httemplate/view/cust_main/payment_history/attempted_payment.html
@@ -0,0 +1,41 @@
+<I>Payment attempt <% $info |h %></I>
+<%init>
+
+my( $cust_pay_pending, %opt ) = @_;
+
+my $date_format = $opt{'date_format'} || '%m/%d/%Y';
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+my $payby = $cust_pay_pending->payby;
+
+my $payinfo;
+if ( $payby eq 'CARD' ) {
+ $payinfo = $cust_pay_pending->paymask;
+} elsif ( $payby eq 'CHEK' ) {
+ my( $account, $aba ) = split('@', $cust_pay_pending->paymask );
+ $payinfo = "ABA $aba, Acct #$account";
+} else {
+ $payinfo = $cust_pay_pending->payinfo;
+}
+
+$payby =~ s/^BILL$/Check #/ if $payinfo;
+$payby =~ s/^CHEK$/Electronic check /;
+$payby =~ s/^PREP$/Prepaid card /;
+$payby =~ s/^CARD$/Credit card #/;
+$payby =~ s/^COMP$/Complimentary by /;
+$payby =~ s/^CASH$/Cash/;
+$payby =~ s/^WEST$/Western Union/;
+$payby =~ s/^MCRD$/Manual credit card/;
+$payby =~ s/^BILL$//;
+my $info = $payby ? "($payby$payinfo)" : '';
+
+if ( $opt{'pkg-balances'} && $cust_pay_pending->pkgnum ) {
+ my $cust_pkg = qsearchs('cust_pkg', { 'pkgnum'=>$cust_pay_pending->pkgnum } );
+ $info .= ' for '. $cust_pkg->pkg_label_long;
+}
+
+$info .= ': '. $cust_pay_pending->statustext
+ if length($cust_pay_pending->statustext);
+
+</%init>
diff --git a/httemplate/view/cust_main/payment_history/payment.html b/httemplate/view/cust_main/payment_history/payment.html
index 6ec9fdb..e745864 100644
--- a/httemplate/view/cust_main/payment_history/payment.html
+++ b/httemplate/view/cust_main/payment_history/payment.html
@@ -155,11 +155,14 @@ my $view =
my $refund = '';
my $refund_days = $opt{'card_refund-days'} || 120;
+my @rights = ('Refund payment');
+push @rights, 'Refund credit card payment' if $payby eq 'CARD';
+push @rights, 'Refund Echeck payment' if $payby eq 'CHEK';
if ( $cust_pay->closed !~ /^Y/i
&& $cust_pay->payby =~ /^(CARD|CHEK)$/
&& time-$cust_pay->_date < $refund_days*86400
&& $cust_pay->unrefunded > 0
- && $curuser->access_right('Refund payment')
+ && $curuser->access_right(\@rights)
) {
$refund = qq! (<A HREF="${p}edit/cust_refund.cgi?payby=$1;!.
qq!paynum=!. $cust_pay->paynum. '"'.
diff --git a/httemplate/view/cust_main/payment_history/pending_payment.html b/httemplate/view/cust_main/payment_history/pending_payment.html
new file mode 100644
index 0000000..40805b1
--- /dev/null
+++ b/httemplate/view/cust_main/payment_history/pending_payment.html
@@ -0,0 +1,61 @@
+<b><font size="+1" color="#FF0000">Pending payment </font></b> <% "$info $status ($link)" %>
+<%init>
+
+my( $cust_pay_pending, %opt ) = @_;
+
+my $conf = new FS::Conf;
+
+my $curuser = $FS::CurrentUser::CurrentUser;
+
+my $payby = $cust_pay_pending->payby;
+
+my $payinfo;
+if ( $payby eq 'CARD' ) {
+ $payinfo = $cust_pay_pending->paymask;
+} elsif ( $payby eq 'CHEK' ) {
+ my( $account, $aba ) = split('@', $cust_pay_pending->paymask );
+ $payinfo = "ABA $aba, Acct #$account";
+} else {
+ $payinfo = $cust_pay_pending->payinfo;
+}
+
+my $target = "$payby$payinfo";
+$payby =~ s/^BILL$/Check #/ if $payinfo;
+$payby =~ s/^CHEK$/Electronic check /;
+$payby =~ s/^PREP$/Prepaid card /;
+$payby =~ s/^CARD$/Credit card #/;
+$payby =~ s/^COMP$/Complimentary by /;
+$payby =~ s/^CASH$/Cash/;
+$payby =~ s/^WEST$/Western Union/;
+$payby =~ s/^MCRD$/Manual credit card/;
+$payby =~ s/^BILL$//;
+my $info = $payby ? "($payby$payinfo)" : '';
+
+my %statusaction = (
+ 'new' => 'delete',
+ 'pending' => 'complete',
+ 'captured' => 'capture',
+);
+
+my $edit_pending =
+ $FS::CurrentUser::CurrentUser->access_right('Edit customer pending payments');
+
+my $status = "Status: ".$cust_pay_pending->status;
+
+my $action = $statusaction{$cust_pay_pending->status};
+
+my $link = "";
+if ( $action && $edit_pending ) {
+ $link = include('/elements/popup_link.html',
+ 'action' => $p. 'edit/cust_pay_pending.html'.
+ '?paypendingnum='. $cust_pay_pending->paypendingnum.
+ ";action=$action",
+ 'label' => $action,
+ 'color' => '#ff0000',
+ 'width' => 655,
+ 'height' => ( $action eq 'delete' ? 480 : 575 ),
+ 'actionlabel' => ucfirst($action). ' pending payment',
+ );
+}
+
+</%init>
diff --git a/httemplate/view/cust_main/payment_history/voided_payment.html b/httemplate/view/cust_main/payment_history/voided_payment.html
index be68ff0..5d7f60c 100644
--- a/httemplate/view/cust_main/payment_history/voided_payment.html
+++ b/httemplate/view/cust_main/payment_history/voided_payment.html
@@ -1,6 +1,10 @@
-<DEL>Payment <% $info %></DEL>
+<DEL>Payment <% $info %> by <% $cust_pay_void->otaker %></DEL>
<I>voided <% time2str($date_format, $cust_pay_void->void_date) %>
-by <% $cust_pay_void->otaker %></I><% $unvoid %>
+% my $void_user = $cust_pay_void->void_access_user;
+% if ($void_user) {
+ by <% $void_user->username %></I>
+% }
+<% $unvoid %>
<%init>
my( $cust_pay_void, %opt ) = @_;
diff --git a/httemplate/view/cust_pay.html b/httemplate/view/cust_pay.html
index 2f23d9e..1408b3d 100644
--- a/httemplate/view/cust_pay.html
+++ b/httemplate/view/cust_pay.html
@@ -2,7 +2,10 @@
<% include('/elements/header-popup.html', "$thing Receipt" ) %>
- <CENTER><A HREF="javascript:self.parent.location = '<% $pr_link %>'">Print</A></CENTER><BR>
+ <div align="center">
+ <A HREF="javascript:self.parent.location = '<% $pr_link %>'">Print</A> |
+ <A HREF="javascript:self.location = '<% $email_link %>'">Re-email</A>
+ </div><BR>
% } elsif ( $link eq 'print' ) {
@@ -15,7 +18,12 @@
)
%>
<BR><BR>
-
+% } elsif ( $link eq 'email' ) {
+% if ( $email_error ) {
+ <% include('/elements/header-popup.html', "Error re-emailing receipt: $email_error" ) %>
+% } else {
+ <% include('/elements/header-popup.html', "Re-emailed receipt" ) %>
+% }
% } else {
<% include('/elements/header.html', "$thing Receipt", menubar(
@@ -26,7 +34,7 @@
% }
-% unless ($link eq 'popup' ) {
+% unless ($link =~ /^(popup|email)$/ ) {
<% include('/elements/small_custview.html',
$custnum,
scalar($conf->config('countrydefault')),
@@ -110,9 +118,14 @@
window.print();
</SCRIPT>
-% }
+% } elsif ( $link eq 'email' ) {
-% if ( $link =~ /^(popup|print)$/ ) {
+ <SCRIPT TYPE="text/javascript">
+ window.top.location.reload();
+ </SCRIPT>
+
+% }
+% if ( $link =~ /^(popup|print|email)$/ ) {
</BODY>
</HTML>
% } else {
@@ -149,6 +162,7 @@ my $cust_pay = qsearchs({
die "$thing #$paynum not found!" unless $cust_pay;
my $pr_link = "${p}view/cust_pay.html?link=print;paynum=$paynum;void=$void";
+my $email_link = "${p}view/cust_pay.html?link=email;paynum=$paynum;void=$void";
my $custnum = $cust_pay->custnum;
my $display_custnum = $cust_pay->cust_main->display_custnum;
@@ -159,4 +173,14 @@ my $money_char = $conf->config('money_char') || '$';
tie my %payby, 'Tie::IxHash', FS::payby->payby2longname;
+my $email_error;
+
+if ( $link eq 'email' ) {
+ my $email_error = $cust_pay->send_receipt(
+ 'manual' => 1,
+ );
+
+ warn "can't send payment receipt/statement: $email_error" if $email_error;
+}
+
</%init>
diff --git a/httemplate/view/elements/svc_Common.html b/httemplate/view/elements/svc_Common.html
index 852640e..8a352f3 100644
--- a/httemplate/view/elements/svc_Common.html
+++ b/httemplate/view/elements/svc_Common.html
@@ -21,6 +21,13 @@
)
</%doc>
+<SCRIPT>
+function areyousure(href) {
+ if (confirm("Permanently delete this <% $label %>?") == true)
+ window.location.href = href;
+}
+</SCRIPT>
+
% if ( $custnum ) {
<% include("/elements/header.html","View $label: $value") %>
@@ -36,18 +43,13 @@
"javascript:areyousure(\'${p}misc/cancel-unaudited.cgi?$svcnum\')"
)) %>
- <SCRIPT>
- function areyousure(href) {
- if (confirm("Permanently delete this <% $label %>?") == true)
- window.location.href = href;
- }
- </SCRIPT>
-
% }
Service #<B><% $svcnum %></B>
% my $url = $opt{'edit_url'} || $p. 'edit/'. $opt{'table'}. '.cgi?';
| <A HREF="<%$url%><%$svcnum%>">Edit this <% $label %></A>
+| <A HREF="javascript:areyousure('<%$p.'misc/unprovision.cgi?'.$svcnum%>')">
+Unprovision this Service</A>
<BR>
<% ntable("#cccccc") %><TR><TD><% ntable("#cccccc",2) %>
diff --git a/httemplate/view/image.cgi b/httemplate/view/image.cgi
new file mode 100644
index 0000000..153ec85
--- /dev/null
+++ b/httemplate/view/image.cgi
@@ -0,0 +1,31 @@
+<% $data %>\
+<%init>
+
+#die "access denied"
+# unless $FS::CurrentUser::CurrentUser->access_right('Configuration');
+
+my $conf = new FS::Conf;
+
+my $type;
+if ( $cgi->param('type') eq 'png' ) {
+ $type = 'png';
+} elsif ( $cgi->param('type') eq 'eps' ) {
+ $type = 'eps';
+} else {
+ die "unknown image type ". $cgi->param('type');
+}
+
+my $data;
+if ( $cgi->param('prefname') =~ /^(\w+)$/ ) {
+
+ my $prefname = $1;
+ my $curuser = $FS::CurrentUser::CurrentUser;
+ $data = decode_base64( $curuser->option("$prefname") );
+
+} else {
+ die "no preview_session specified";
+}
+
+http_header('Content-Type' => 'image/png' );
+
+</%init>
diff --git a/httemplate/view/svc_acct/communigate.html b/httemplate/view/svc_acct/communigate.html
index 0f090fd..179facf 100644
--- a/httemplate/view/svc_acct/communigate.html
+++ b/httemplate/view/svc_acct/communigate.html
@@ -32,6 +32,14 @@
<% include('/view/elements/tr.html', label=>'Add trailer to sent mail',
value=>$svc_acct->cgp_addmailtrailer ? 'YES' : 'NO' ) %>
+% my $archive_after = $svc_acct->cgp_archiveafter;
+% $archive_after =
+% $archive_after
+% ? ( $archive_after / 86400 ). ' days'
+% : ( $archive_after eq '0' ? 'Never' : 'default (730 days)' );
+ <% include('/view/elements/tr.html', label=>'Archive messages after',
+ value=>$archive_after, ) %>
+
%# preferences
<% include('/view/elements/tr.html', label=>'Message delete method',
@@ -54,17 +62,16 @@
value=>$svc_acct->cgp_sendmdnmode ) %>
%# vacation message
-%#XXX finish me... do we need to search for specific rules
-%# (and hide them?) need to see what CGP gives back after we've added a rule
<% include('/elements/init_overlib.html') %>
<TR>
<TD ALIGN="right">Vacation message</TD>
<TD BGCOLOR="#FFFFFF">
+ <% $vacation_rule ? 'Active' : '' %>
<% include('/elements/popup_link.html',
'action' => $p.'edit/cgp_rule-vacation.html?'.
'svcnum='. $svc_acct->svcnum,
- 'label' => '(add)', #XXX (edit)
+ 'label' => $vacation_rule ? '(edit)' : '(add)',
'actionlabel' => 'Vacation message',
'width' => 600,
'height' => 300,
@@ -75,15 +82,15 @@
</TR>
%# redirect all mail
-%#XXX finish me...
<TR>
<TD ALIGN="right">Redirect all mail</TD>
<TD BGCOLOR="#FFFFFF">
+ <% $redirect_rule ? 'Active' : '' %>
<% include('/elements/popup_link.html',
'action' => $p.'edit/cgp_rule-redirect_all.html?'.
'svcnum='. $svc_acct->svcnum,
- 'label' => '(add)', #XXX (edit)
+ 'label' => $redirect_rule ? '(edit)' : '(add)',
'actionlabel' => 'Redirect all mail',
'width' => 763,
#'height'
@@ -100,6 +107,13 @@
)
%>
+%# RPOP
+
+ <% include('/view/elements/tr.html', label=>'Remote POP accounts',
+ value=>$rpop_link,
+ )
+ %>
+
<%init>
my %opt = @_;
@@ -109,7 +123,20 @@ my %opt = @_;
my $svc_acct = $opt{'svc_acct'};
#my $part_svc = $opt{'part_svc'};
-my $rule_link = qq(<A HREF="${p}browse/cgp_rule.html?svcnum=).
+my $rule_link = qq(<A HREF="${p}browse/cgp_rule.html?svcnum=). #"dum vim
$svc_acct->svcnum. '">View/edit mail rules</A>';
+my $rpop_link = qq(<A HREF="${p}browse/acct_snarf.html?svcnum=). #"dee vim
+ $svc_acct->svcnum. '">View/edit remote POP accounts</A>';
+
+my $vacation_rule = qsearchs('cgp_rule', { 'svcnum' => $svc_acct->svcnum,
+ 'name' => '#Vacation'
+ }
+ );
+
+my $redirect_rule = qsearchs('cgp_rule', { 'svcnum' => $svc_acct->svcnum,
+ 'name' => '#Redirect'
+ }
+ );
+
</%init>
diff --git a/httemplate/view/svc_domain/acct_defaults.html b/httemplate/view/svc_domain/acct_defaults.html
index 3a4e187..b561282 100644
--- a/httemplate/view/svc_domain/acct_defaults.html
+++ b/httemplate/view/svc_domain/acct_defaults.html
@@ -71,6 +71,14 @@
)
%>
+% my $archive_after = $svc_domain->acct_def_cgp_archiveafter;
+% $archive_after =
+% $archive_after
+% ? ( $archive_after / 86400 ). ' days'
+% : ( $archive_after eq '0' ? 'Never' : 'default (730 days)' );
+ <% include('/view/elements/tr.html', label=>'Archive messages after',
+ value=>$archive_after, ) %>
+
%# preferences
<% include('/view/elements/tr.html',
diff --git a/httemplate/view/svc_domain/dns.html b/httemplate/view/svc_domain/dns.html
index 88a9bda..184286c 100644
--- a/httemplate/view/svc_domain/dns.html
+++ b/httemplate/view/svc_domain/dns.html
@@ -7,22 +7,30 @@
return confirm("Remove all records and slave from " + document.SlaveForm.recdata.value + "?");
}
</SCRIPT>
+<% include('/elements/init_overlib.html') %>
-DNS records
-% my @records; if ( @records = $svc_domain->domain_record ) {
+<A NAME="dns"></A>
+<div class="fscontainer">
+<div class="fsbox">
+<div class="fsbox-title">
+ <span class="left">DNS Records</span>
+</div>
- <% include('/elements/table-grid.html') %>
+<% include('/elements/table-grid.html') %>
% my $bgcolor1 = '#eeeeee';
-% my $bgcolor2 = '#ffffff';
-% my $bgcolor = $bgcolor2;
+% my $bgcolor2 = '#ffffff';
+% my $bgcolor = $bgcolor2;
<tr>
<th CLASS="grid" BGCOLOR="#cccccc">Zone</th>
<th CLASS="grid" BGCOLOR="#cccccc">Type</th>
<th CLASS="grid" BGCOLOR="#cccccc">Data</th>
+ <th CLASS="grid" BGCOLOR="#cccccc">TTL</th>
+ <th CLASS="grid" BGCOLOR="#cccccc"></th>
</tr>
+% my @records = $svc_domain->domain_record;
% foreach my $domain_record ( @records ) {
% my $type = $domain_record->rectype eq '_mstr'
% ? "(slave)"
@@ -32,13 +40,27 @@ DNS records
<tr>
<td CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $domain_record->reczone %></td>
<td CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $type %></td>
- <td CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $domain_record->recdata %>
+ <td CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $domain_record->recdata %></td>
+ <td CLASS="grid" BGCOLOR="<% $bgcolor %>"><% $domain_record->ttl %></td>
+ <td CLASS="grid" BGCOLOR="<% $bgcolor %>">
% unless ( $domain_record->rectype eq 'SOA'
% || ! $FS::CurrentUser::CurrentUser->access_right('Edit domain nameservice')
% ) {
+% my $edit_link = include('/elements/popup_link.html',
+% 'label' => 'edit',
+% 'action' => $p.'edit/domain_record.html?recnum='.
+% $domain_record->recnum,
+% 'actionlabel' => 'Edit nameservice record',
+% 'width' => 655,
+% 'height' => 176,
+% #'color' => '#ff0000',
+% );
% ( my $recdata = $domain_record->recdata ) =~ s/"/\\'\\'/g;
- (<A HREF="javascript:areyousure('<%$p%>misc/delete-domain_record.cgi?<%$domain_record->recnum%>', 'Delete \'<% $domain_record->reczone %> <% $type %> <% $recdata %>\' ?' )">delete</A>)
+% my $delete_url= "javascript:areyousure('${p}misc/delete-domain_record.cgi?".
+% $domain_record->recnum. "', 'Delete ".
+% $domain_record->reczone. " $type $recdata ?' )";
+ <%$edit_link%>&nbsp;|&nbsp;<A HREF="<%$delete_url%>">delete</A>
% }
</td>
</tr>
@@ -52,23 +74,51 @@ DNS records
% }
- </table>
-% }
+% if ( ! @records ) {
+
+ <FORM METHOD="POST" NAME="DefaultForm" ACTION="<%$p%>edit/process/svc_domain-defaultrecords.cgi">
+ <tr>
+ <td class="grid" BGCOLOR="#ffffff" COLSPAN=5>
+ <INPUT TYPE="hidden" NAME="svcnum" VALUE="<%$svcnum%>">
+ <INPUT TYPE="submit" VALUE="Add default records">
+ </td>
+ </tr>
+ </FORM>
+
+% }
% if ( $FS::CurrentUser::CurrentUser->access_right('Edit domain nameservice') ) {
<FORM METHOD="POST" ACTION="<%$p%>edit/process/domain_record.cgi">
- <INPUT TYPE="hidden" NAME="svcnum" VALUE="<%$svcnum%>">
- <INPUT TYPE="text" NAME="reczone">
- <INPUT TYPE="hidden" NAME="recaf" VALUE="IN"> IN
- <SELECT NAME="rectype">
-% foreach (qw( A NS CNAME MX PTR TXT) ) {
- <OPTION VALUE="<%$_%>"><%$_%></OPTION>
-% }
- </SELECT>
- <INPUT TYPE="text" NAME="recdata">
- <INPUT TYPE="submit" VALUE="Add record">
+ <INPUT TYPE="hidden" NAME="svcnum" VALUE="<%$svcnum%>">
+ <tr>
+ <td class="grid" bgcolor="<%$bgcolor%>">
+ <INPUT TYPE="text" NAME="reczone"><BR>
+ <FONT SIZE="-1"><I>Zone</I></FONT>
+ </TD>
+ <TD class="grid" bgcolor="<%$bgcolor%>">
+ <INPUT TYPE="hidden" NAME="recaf" VALUE="IN">
+ <SELECT NAME="rectype">
+% foreach ( @{ FS::domain_record->rectypes } ) {
+ <OPTION VALUE="<%$_%>">IN <%$_%></OPTION>
+% }
+ </SELECT><BR>
+ <FONT SIZE="-1"><I>Type</I></FONT>
+ </TD>
+ <TD class="grid" bgcolor="<%$bgcolor%>">
+ <INPUT TYPE="text" NAME="recdata"><BR>
+ <FONT SIZE="-1"><I>Data</I></FONT>
+ </TD>
+ <TD class="grid" bgcolor="<%$bgcolor%>">
+ <INPUT TYPE="text" NAME="ttl" size="6"><BR>
+ <FONT SIZE="-1"><I>TTL</I></FONT>
+ </TD>
+ <TD class="grid" bgcolor="<%$bgcolor%>" VALIGN="top">
+ <INPUT TYPE="submit" VALUE="Add record">
+ </TD>
+ </TR>
</FORM>
+ <BR>
<FORM NAME="SlaveForm" METHOD="POST" ACTION="<%$p%>edit/process/domain_record.cgi">
<INPUT TYPE="hidden" NAME="svcnum" VALUE="<%$svcnum%>">
Or
@@ -83,9 +133,14 @@ DNS records
<INPUT TYPE="text" NAME="recdata">
<INPUT TYPE="submit" VALUE="Slave domain" onClick="return slave_areyousure()">
</FORM>
+ <BR><BR>
% }
+</table>
+
+</div>
+</div>
<%init>
my($svc_domain, %opt) = @_;
diff --git a/httemplate/view/svc_pbx.cgi b/httemplate/view/svc_pbx.cgi
new file mode 100644
index 0000000..79cafed
--- /dev/null
+++ b/httemplate/view/svc_pbx.cgi
@@ -0,0 +1,72 @@
+<% include('elements/svc_Common.html',
+ 'table' => 'svc_pbx',
+ 'edit_url' => $p."edit/svc_Common.html?svcdb=svc_pbx;svcnum=",
+ 'labels' => \%labels,
+ 'html_foot' => $html_foot,
+ )
+%>
+<%init>
+
+my $fields = FS::svc_pbx->table_info->{'fields'};
+my %labels = map { $_ => ( ref($fields->{$_})
+ ? $fields->{$_}{'label'}
+ : $fields->{$_}
+ );
+ }
+ keys %$fields;
+
+my $html_foot = sub {
+ my $svc_pbx = shift;
+
+ ##
+ # CDR links
+ ##
+
+ tie my %what, 'Tie::IxHash',
+ 'pending' => 'NULL',
+ 'billed' => 'done',
+ ;
+
+ #matching as per package def cdr_svc_method
+ my $cust_pkg = $svc_pbx->cust_svc->cust_pkg;
+ return '' unless $cust_pkg;
+
+ my @voip_pkgs =
+ grep { $_->plan eq 'voip_cdr' } $cust_pkg->part_pkg->self_and_bill_linked;
+ if ( scalar(@voip_pkgs) > 1 ) {
+ warn "multiple voip_cdr packages bundled\n";
+ return '';
+ } elsif ( !@voip_pkgs ) {
+ warn "no voip_cdr packages\n";
+ }
+ my $voip_pkg = @voip_pkgs[0];
+
+ my $cdr_svc_method = $voip_pkg->option('cdr_svc_method')
+ || 'svc_phone.phonenum';
+ return '' unless $cdr_svc_method =~ /^svc_pbx\.(\w+)$/;
+ my $field = $1;
+
+ my $search;
+ if ( $field eq 'title' ) {
+ $search = 'charged_party='. uri_escape($svc_pbx->title);
+ } elsif ( $field eq 'svcnum' ) {
+ $search = 'svcnum='. $svc_pbx->svcnum;
+ } else {
+ warn "unknown cdr_svc_method svc_pbx.$field";
+ return '';
+ }
+
+ my @links = map {
+ qq(<A HREF="${p}search/cdr.html?cdrbatchnum=__ALL__;$search;freesidestatus=$what{$_}">).
+ "View $_ CDRs</A>";
+ } keys(%what);
+
+ ###
+ # concatenate & return
+ ###
+
+ join(' | ', @links ). '<BR>';
+
+};
+
+</%init>
diff --git a/httemplate/view/svc_phone.cgi b/httemplate/view/svc_phone.cgi
index 75591c7..27d270c 100644
--- a/httemplate/view/svc_phone.cgi
+++ b/httemplate/view/svc_phone.cgi
@@ -120,19 +120,28 @@ my $html_foot = sub {
'billed' => 'done',
;
- #XXX src & charged party (& default prefix) as per voip_cdr.pm
- #XXX handle toll free too
-
my $number = $svc_phone->phonenum;
$number = $svc_phone->countrycode. $number
unless $svc_phone->countrycode eq '1';
+ #src & charged party as per voip_cdr.pm
+ my $search;
+ my $cust_pkg = $svc_phone->cust_svc->cust_pkg;
+ if ( $cust_pkg && $cust_pkg->part_pkg->option('disable_src') ) {
+ $search = "charged_party=$number";
+ } else {
+ $search = "charged_party_or_src=$number";
+ }
+
+ #XXX default prefix as per voip_cdr.pm
+ #XXX handle toll free too
+
#my @links = map {
# qq(<A HREF="${p}search/cdr.html?src=$number;freesidestatus=$what{$_}">).
# "View $_ CDRs</A>";
#} keys(%what);
my @links = map {
- qq(<A HREF="${p}search/cdr.html?cdrbatchnum=__ALL__;charged_party=$number;freesidestatus=$what{$_}">).
+ qq(<A HREF="${p}search/cdr.html?cdrbatchnum=__ALL__;$search;freesidestatus=$what{$_}">).
"View $_ CDRs</A>";
} keys(%what);
diff --git a/rpm/freeside.spec b/rpm/freeside.spec
index d9d2596..548444e 100644
--- a/rpm/freeside.spec
+++ b/rpm/freeside.spec
@@ -1,5 +1,5 @@
%{!?_initrddir:%define _initrddir /etc/rc.d/init.d}
-%{!?version:%define version 2.1.0}
+%{!?version:%define version 2.1.1}
%{!?release:%define release 8}
Summary: Freeside ISP Billing System
diff --git a/rt/FREESIDE_MODIFIED b/rt/FREESIDE_MODIFIED
index 71ec135..45a6106 100644
--- a/rt/FREESIDE_MODIFIED
+++ b/rt/FREESIDE_MODIFIED
@@ -4,10 +4,15 @@ config.layout.in
etc/RT_Config.pm.in
etc/RT_Config.pm
etc/RT_SiteConfig.pm
+ etc/schema.Pg
+ etc/schema.mysql-4.1
+
lib/RT/Config.pm
+lib/RT/CustomField.pm #CheckMandatoryFields
lib/RT/CustomField_Overlay.pm #customfield date patch
lib/RT/Interface/Web.pm #customfield date patch
lib/RT/Interface/Web_Vendor.pm
+ lib/RT/Interface/Web/Handler.pm #freeside comp_root for dashboard emails
lib/RT/Record.pm #and customfield date patch
lib/RT/SearchBuilder.pm #need DBIx::SearchBuilder >= 1.36 for Pg 8.1+
lib/RT/Transaction_Overlay.pm
@@ -18,15 +23,15 @@ lib/RT/Tickets_Overlay.pm #customfield date patch
lib/RT/URI/freeside.pm
lib/RT/URI/freeside/Internal.pm
lib/RT/URI/freeside/XMLRPC.pm
+
+ share/html/autohandler #Footer getting appended where unwelcome
+ share/html/Admin/CustomFields/Modify.html #CheckMandatoryFields
share/html/Admin/Users/Modify.html
- share/html/Elements/ColumnMap
share/html/Elements/CollectionList
share/html/Elements/EditCustomFieldDate #customfield date patch (NEW)
share/html/Elements/Header
share/html/Elements/PageLayout
#html/Elements/QuickCreate
- share/html/Elements/RefreshHomepage
- share/html/Elements/RT__CustomField/ColumnMap
share/html/Elements/RT__Ticket/ColumnMap
share/html/Elements/ShowCustomFieldDate #customfield date patch (NEW)
share/html/Elements/SelectDate
@@ -40,6 +45,7 @@ share/html/Elements/ShowLink_Checklist
share/html/Ticket/Checklist.html
share/html/Ticket/Display.html
share/html/Ticket/Elements/AddCustomers
+ share/html/Ticket/Elements/CheckMandatoryFields
share/html/Ticket/Elements/EditCustomers
share/html/Ticket/Elements/ShowCustomers
share/html/Ticket/Elements/ShowMembers_Checklist
@@ -47,7 +53,6 @@ share/html/Ticket/Elements/ShowMembers_Checklist
share/html/Ticket/Elements/ShowSummary
share/html/Ticket/Elements/ShowTransactionAttachments
share/html/Ticket/Elements/Tabs
- share/html/Ticket/Graph/index.html
share/html/Ticket/ModifyCustomers.html
html/NoAuth/css/3.5-default/main.css
html/NoAuth/css/3.5-default/misc.css
@@ -66,3 +71,6 @@ share/html/Elements/EditCustomers
share/html/Widgets/TitleBoxEnd
share/html/Callbacks/RTx-Checklist/*
+
+share/html/Callbacks/CheckMandatoryFields/*
+
diff --git a/rt/etc/schema.Pg b/rt/etc/schema.Pg
index 48525c8..e3006d0 100755
--- a/rt/etc/schema.Pg
+++ b/rt/etc/schema.Pg
@@ -539,6 +539,7 @@ CREATE TABLE CustomFields (
LastUpdatedBy integer NOT NULL DEFAULT 0 ,
LastUpdated TIMESTAMP NULL ,
Disabled integer NOT NULL DEFAULT 0 ,
+ Required integer NOT NULL DEFAULT 0 ,
PRIMARY KEY (id)
);
diff --git a/rt/etc/schema.mysql-4.1 b/rt/etc/schema.mysql-4.1
index 172e477..1735702 100755
--- a/rt/etc/schema.mysql-4.1
+++ b/rt/etc/schema.mysql-4.1
@@ -386,6 +386,7 @@ CREATE TABLE CustomFields (
LastUpdatedBy integer NOT NULL DEFAULT 0 ,
LastUpdated DATETIME NULL ,
Disabled int2 NOT NULL DEFAULT 0 ,
+ Required int2 NOT NULL DEFAULT 0 ,
PRIMARY KEY (id)
) TYPE=InnoDB CHARACTER SET utf8;
diff --git a/rt/lib/RT/CustomField.pm b/rt/lib/RT/CustomField.pm
index 995728f..dc41080 100644
--- a/rt/lib/RT/CustomField.pm
+++ b/rt/lib/RT/CustomField.pm
@@ -122,6 +122,7 @@ sub Create {
Disabled => '0',
LinkToValue => '',
IncludeContentForValue => '',
+ Required => '0',
@_);
$self->SUPER::Create(
@@ -381,6 +382,8 @@ sub _CoreAccessible {
{read => 1, auto => 1, sql_type => 11, length => 0, is_blob => 0, is_numeric => 0, type => 'datetime', default => ''},
Disabled =>
{read => 1, write => 1, sql_type => 5, length => 6, is_blob => 0, is_numeric => 1, type => 'smallint(6)', default => '0'},
+ Required =>
+ {read => 1, write => 1, sql_type => 5, length => 6, is_blob => 0, is_numeric => 1, type => 'smallint(6)', default => '0'},
}
};
diff --git a/rt/lib/RT/Ticket_Overlay.pm b/rt/lib/RT/Ticket_Overlay.pm
index b60ae38..8d04742 100644
--- a/rt/lib/RT/Ticket_Overlay.pm
+++ b/rt/lib/RT/Ticket_Overlay.pm
@@ -2312,6 +2312,16 @@ sub _Links {
return $links;
}
+ # without this you will also get RT::User(s) instead of tickets!
+ if ($field == 'Base' and $type == 'MemberOf') {
+ my $rtname = RT->Config->Get('rtname');
+ $links->Limit(
+ FIELD => 'Base',
+ OPERATOR => 'STARTSWITH',
+ VALUE => "fsck.com-rt://$rtname/ticket/",
+ );
+ }
+
# Maybe this ticket is a merge ticket
my $limit_on = 'Local'. $field;
# at least to myself
diff --git a/rt/lib/RT/Tickets_Overlay.pm b/rt/lib/RT/Tickets_Overlay.pm
index f2949ed..be5a0d5 100644
--- a/rt/lib/RT/Tickets_Overlay.pm
+++ b/rt/lib/RT/Tickets_Overlay.pm
@@ -1855,7 +1855,13 @@ sub OrderByCols {
);
#if there was a Links.RemoteTarget int, this bs wouldn't be necessary
- my $custnum_sql = "CAST(SUBSTR($linkalias.Target,31) AS INTEGER)";
+ my $custnum_sql = "CAST(SUBSTR($linkalias.Target,31) AS ";
+ if ( RT->Config->Get('DatabaseType') eq 'mysql' ) {
+ $custnum_sql .= 'SIGNED INTEGER)';
+ }
+ else {
+ $custnum_sql .= 'INTEGER)';
+ }
if ( $subkey eq 'Number' ) {
diff --git a/rt/lib/RT/URI/freeside/Internal.pm b/rt/lib/RT/URI/freeside/Internal.pm
index bd7c42c..b5af134 100644
--- a/rt/lib/RT/URI/freeside/Internal.pm
+++ b/rt/lib/RT/URI/freeside/Internal.pm
@@ -104,13 +104,13 @@ sub FreesideGetConfig {
sub smart_search { #Subroutine
- return map { { $_->hash } } &FS::cust_main::smart_search(@_);
+ return map { { $_->hash } } &FS::cust_main::Search::smart_search(@_);
}
sub email_search { #Subroutine
- return map { { $_->hash } } &FS::cust_main::email_search(@_);
+ return map { { $_->hash } } &FS::cust_main::Search::email_search(@_);
}
diff --git a/rt/share/html/Admin/CustomFields/Modify.html b/rt/share/html/Admin/CustomFields/Modify.html
index f75607a..fc2c49d 100644
--- a/rt/share/html/Admin/CustomFields/Modify.html
+++ b/rt/share/html/Admin/CustomFields/Modify.html
@@ -119,6 +119,11 @@
% }
<tr><td class="label">&nbsp;</td><td>
+<input type="checkbox" class="checkbox" name="Required" value="1" <% $RequiredChecked |n%> />
+<&|/l&>Required for ticket resolution</&>
+</td></tr>
+
+<tr><td class="label">&nbsp;</td><td>
<input type="hidden" class="hidden" name="SetEnabled" value="1" />
<input type="checkbox" class="checkbox" name="Enabled" value="1" <% $EnabledChecked |n%> />
<&|/l&>Enabled (Unchecking this box disables this custom field)</&>
@@ -171,11 +176,12 @@ else {
}
if ( $ARGS{'Update'} && $id ne 'new' ) {
-
#we're asking about enabled on the web page but really care about disabled.
$ARGS{'Disabled'} = $Disabled = $Enabled? 0 : 1;
+
+ $ARGS{'Required'} ||= 0;
- my @attribs = qw(Disabled Pattern Name TypeComposite LookupType Description LinkValueTo IncludeContentForValue);
+ my @attribs = qw(Disabled Required Pattern Name TypeComposite LookupType Description LinkValueTo IncludeContentForValue);
push @results, UpdateRecordObject(
AttributesRef => \@attribs,
Object => $CustomFieldObj,
@@ -193,6 +199,15 @@ if ( $ARGS{'Update'} && $id ne 'new' ) {
push (@results, $msg);
}
+ # Clean up values
+ foreach my $param (grep /^$paramtag-/, keys(%ARGS)) {
+ for ($ARGS{$param}) {
+ s/\r+\n/\n/g;
+ s/^\s+//;
+ s/\s+$//;
+ }
+ }
+
# Update any existing values
my $values = $CustomFieldObj->ValuesObj;
while ( my $value = $values->Next ) {
@@ -200,7 +215,6 @@ if ( $ARGS{'Update'} && $id ne 'new' ) {
my $param = join("-", $paramtag, $value->Id, $attr);
next unless exists $ARGS{$param};
next if ($value->$attr()||'') eq ($ARGS{$param}||'');
-
my $mutator = "Set$attr";
my ($id, $msg) = $value->$mutator( $ARGS{$param} );
push (@results, $msg);
@@ -222,6 +236,9 @@ $id = $CustomFieldObj->id if $CustomFieldObj->id;
my $EnabledChecked = qq[checked="checked"];
$EnabledChecked = '' if $CustomFieldObj->Disabled;
+my $RequiredChecked = '';
+$RequiredChecked = qq[checked="checked"] if $CustomFieldObj->Required;
+
my @CFvalidations = (
'(?#Mandatory).',
'(?#Digits)^[\d.]+$',
diff --git a/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Elements/Tabs/Default b/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Elements/Tabs/Default
new file mode 100644
index 0000000..2c0698e
--- /dev/null
+++ b/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Elements/Tabs/Default
@@ -0,0 +1,12 @@
+<%doc>
+If mandatory fields aren't set yet, point the "Resolve" link back
+to "Ticket Basics".
+</%doc>
+<%init>
+my $TicketObj = delete($ARGS{'Ticket'});
+my $actions = $ARGS{'actions'};
+if( $m->comp('/Ticket/Elements/CheckMandatoryFields', Ticket => $TicketObj)
+ ) {
+ $actions->{'G'}->{'path'} = 'Ticket/Modify.html?id='.$TicketObj->Id.'&resolve=1';
+}
+</%init>
diff --git a/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Modify.html/BeforeActionList b/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Modify.html/BeforeActionList
new file mode 100644
index 0000000..4779411
--- /dev/null
+++ b/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Modify.html/BeforeActionList
@@ -0,0 +1,15 @@
+<%init>
+use Data::Dumper;
+my $ARGSRef = $ARGS{'ARGSRef'};
+my $TicketObj = $ARGS{'Ticket'};
+my $results = $ARGS{'Actions'};
+if(defined($ARGSRef->{'resolve'})) {
+ my @errors =
+ $m->comp('/Ticket/Elements/CheckMandatoryFields', Ticket => $TicketObj);
+ return if !@errors;
+ my $msg = 'Missing required field'.(@errors > 1 ? 's' : '').': ' .
+ join(', ', map { $_->Name } @errors);
+ $m->notes( ('InvalidField-' . $_->Id) => 'Required' ) foreach @errors;
+ push @$results, $msg;
+}
+</%init>
diff --git a/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Update.html/BeforeDisplay b/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Update.html/BeforeDisplay
new file mode 100644
index 0000000..0d69bc2
--- /dev/null
+++ b/rt/share/html/Callbacks/CheckMandatoryFields/Ticket/Update.html/BeforeDisplay
@@ -0,0 +1,24 @@
+<%doc>
+When the user tries to change a ticket's status to "resolved" through
+the Update interface, check mandatory fields. If they aren't all set,
+redirect to Ticket Basics instead of updating. Note that this will
+lose any comments/time/other information the user has entered.
+</%doc>
+
+<%init>
+my $TicketObj = $ARGS{'Ticket'};
+my $ARGSRef = $ARGS{'ARGSRef'};
+my $oldStatus = $TicketObj->Status();
+my $newStatus = $ARGSRef->{'Status'} || $ARGSRef->{'DefaultStatus'};
+if( $oldStatus ne 'resolved' and
+ $newStatus eq 'resolved' and
+ $m->comp('/Ticket/Elements/CheckMandatoryFields',
+ Ticket => $TicketObj
+ ) ) {
+ $m->clear_buffer;
+ RT::Interface::Web::Redirect(
+ RT->Config->Get('WebURL')."Ticket/Modify.html?id=".$TicketObj->Id."&resolve=1"
+ );
+ $m->abort;
+}
+</%init>
diff --git a/rt/share/html/Elements/AddCustomers b/rt/share/html/Elements/AddCustomers
index aaf8ca8..9828d7d 100644
--- a/rt/share/html/Elements/AddCustomers
+++ b/rt/share/html/Elements/AddCustomers
@@ -39,7 +39,10 @@ warn "/Elements/AddCustomers called with CustomerString $CustomerString\n"
my @Customers = ();
if ( $CustomerString ) {
- @Customers = &RT::URI::freeside::smart_search( 'search' => $CustomerString );
+ @Customers = &RT::URI::freeside::smart_search(
+ 'search' => $CustomerString,
+ 'no_fuzzy_on_exact' => 1, #pref?
+ );
}
my @Services = ();
diff --git a/rt/share/html/Elements/ColumnMap b/rt/share/html/Elements/ColumnMap
index 6a1c237..a1475a9 100644
--- a/rt/share/html/Elements/ColumnMap
+++ b/rt/share/html/Elements/ColumnMap
@@ -157,12 +157,7 @@ my $COLUMN_MAP = {
} qw(WebPath WebBaseURL WebURL)),
WebRequestPath => { value => sub { substr( $m->request_path, 1 ) } },
- #WebRequestPathDir => { value => sub { substr( $m->request_comp->dir_path, 1 ) } },
- WebRequestPathDir => { value => sub {
- my $wrpd = substr( $m->request_comp->dir_path, 1 );
- $wrpd =~ s(^rt/)(); #hacky, dunno why this happens
- $wrpd;
- } },
+ WebRequestPathDir => { value => sub { substr( $m->request_comp->dir_path, 1 ) } },
};
$COLUMN_MAP->{'CF'} = $COLUMN_MAP->{'CustomField'};
diff --git a/rt/share/html/Elements/Header b/rt/share/html/Elements/Header
index d970ac5..72e3e72 100755
--- a/rt/share/html/Elements/Header
+++ b/rt/share/html/Elements/Header
@@ -45,14 +45,14 @@
%# those contributions and any derivatives thereof.
%#
%# END BPS TAGGED BLOCK }}}
-<% include( '/elements/header.html', {
+<& /elements/header.html, {
'title' => $Title,
'head' => $head,
'etc' => $etc,
'nobr' => 1,
'nocss' => 1,
- }) |n
-%>
+ }
+&>
<%INIT>
$r->headers_out->{'Pragma'} = 'no-cache';
$r->headers_out->{'Cache-control'} = 'no-cache';
diff --git a/rt/share/html/Elements/RT__CustomField/ColumnMap b/rt/share/html/Elements/RT__CustomField/ColumnMap
index 0a867eb..6d8d76a 100644
--- a/rt/share/html/Elements/RT__CustomField/ColumnMap
+++ b/rt/share/html/Elements/RT__CustomField/ColumnMap
@@ -155,9 +155,7 @@ my $COLUMN_MAP = {
: ($args->{'PassArguments'});
my %pass = map { $_ => $args->{$_} } grep exists $args->{$_}, @pass;
- my $path = $m->request_path;
- $path =~ s(^/rt)(); #hacky, dunno why this happens
- my $uri = RT->Config->Get('WebPath') . $path;
+ my $uri = RT->Config->Get('WebPath') . $m->request_path;
my @res = (
\'<a href="',
diff --git a/rt/share/html/Elements/RefreshHomepage b/rt/share/html/Elements/RefreshHomepage
index 7840f59..bf91a95 100644
--- a/rt/share/html/Elements/RefreshHomepage
+++ b/rt/share/html/Elements/RefreshHomepage
@@ -46,13 +46,9 @@
%#
%# END BPS TAGGED BLOCK }}}
<&|/Widgets/TitleBox, title => loc('Refresh')&>
-<form method="get" action="<% RT->Config->Get('WebPath') . $path %>">
+<form method="get" action="<% RT->Config->Get('WebPath') . $m->request_path %>">
<& /Elements/Refresh, Name => 'HomeRefreshInterval',
Default => $session{'home_refresh_interval'}||RT->Config->Get('HomePageRefreshInterval', $session{'CurrentUser'}) &>
<& /Elements/Submit, Label => loc('Go!') &>
</&>
</form>
-<%init>
-my $path = $m->request_path;
-$path =~ s(^/rt)(); #hacky, dunno why this happens
-</%init>
diff --git a/rt/share/html/Ticket/Elements/AddCustomers b/rt/share/html/Ticket/Elements/AddCustomers
index e04c077..09acdfd 100644
--- a/rt/share/html/Ticket/Elements/AddCustomers
+++ b/rt/share/html/Ticket/Elements/AddCustomers
@@ -36,7 +36,10 @@ my $freeside_url = &RT::URI::freeside::FreesideURL();
my @Customers = ();
if ( $CustomerString ) {
- @Customers = &RT::URI::freeside::smart_search( 'search' => $CustomerString );
+ @Customers = &RT::URI::freeside::smart_search(
+ 'search' => $CustomerString,
+ 'no_fuzzy_on_exact' => 1, #pref?
+ );
}
my @Services = ();
diff --git a/rt/share/html/Ticket/Elements/CheckMandatoryFields b/rt/share/html/Ticket/Elements/CheckMandatoryFields
new file mode 100644
index 0000000..3d0324f
--- /dev/null
+++ b/rt/share/html/Ticket/Elements/CheckMandatoryFields
@@ -0,0 +1,9 @@
+<%init>
+
+my $TicketObj = $ARGS{'Ticket'} or return ();
+my $ARGSRef = $ARGS{'ARGSRef'};
+my @fields = grep { $_->Required }
+ @{ $TicketObj->CustomFields->ItemsArrayRef };
+return grep { !defined($TicketObj->FirstCustomFieldValue($_->id)) } @fields;
+
+</%init>
diff --git a/rt/share/html/Ticket/Graphs/index.html b/rt/share/html/Ticket/Graphs/index.html
index 211d3a0..e23737c 100644
--- a/rt/share/html/Ticket/Graphs/index.html
+++ b/rt/share/html/Ticket/Graphs/index.html
@@ -56,9 +56,7 @@
<& Elements/ShowGraph, %ARGS, Ticket => $ticket &>
-% my $path = $m->request_comp->path;
-% $path =~ s(^/rt)(); #hacky, dunno why this happens
-<form action="<% RT->Config->Get('WebPath') . $path %>">
+<form action="<% RT->Config->Get('WebPath') . $m->request_comp->path %>">
<input type="hidden" class="hidden" name="id" value="<% $id %>" />
<& Elements/EditGraphProperties, %ARGS, Ticket => $ticket &>
diff --git a/rt/share/html/autohandler b/rt/share/html/autohandler
index 1bb2c93..b44bd68 100755
--- a/rt/share/html/autohandler
+++ b/rt/share/html/autohandler
@@ -52,7 +52,9 @@ $m->callback( ARGSRef => \%ARGS, CallbackName => 'Init', CallbackPage => '/autoh
RT::Interface::Web::HandleRequest(\%ARGS);
-$m->comp( '/Elements/Footer', %ARGS );
+$m->comp( '/Elements/Footer', %ARGS )
+ unless $r->content_type =~ qr<^(text|application)/(x-)?(css|javascript)>;
+
</%INIT>
<%ARGS>
$user => undef