diff options
Diffstat (limited to 'trunk/etherpad/src/etherpad/billing')
-rw-r--r-- | trunk/etherpad/src/etherpad/billing/billing.js | 800 | ||||
-rw-r--r-- | trunk/etherpad/src/etherpad/billing/fields.js | 219 | ||||
-rw-r--r-- | trunk/etherpad/src/etherpad/billing/team_billing.js | 422 |
3 files changed, 1441 insertions, 0 deletions
diff --git a/trunk/etherpad/src/etherpad/billing/billing.js b/trunk/etherpad/src/etherpad/billing/billing.js new file mode 100644 index 0000000..444c233 --- /dev/null +++ b/trunk/etherpad/src/etherpad/billing/billing.js @@ -0,0 +1,800 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dateutils.*"); +import("fastJSON"); +import("jsutils.eachProperty"); +import("netutils.urlPost"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("stringutils.{md5,repeat}"); + +import("etherpad.log.{custom=>eplog}"); + + +jimport("java.lang.System.out.println"); + +function clearKeys(obj, keys) { + var newObj = {}; + eachProperty(obj, function(k, v) { + var isCopied = false; + keys.forEach(function(key) { + if (k == key.name && + key.valueTest(v)) { + newObj[k] = key.valueReplace(v); + isCopied = true; + } + }); + if (! isCopied) { + if (typeof(obj[k]) == 'object') { + newObj[k] = clearKeys(v, keys); + } else { + newObj[k] = v; + } + } + }); + return newObj; +} + +function replaceWithX(s) { + return repeat("X", s.length); +} + +function log(obj) { + eplog('billing', clearKeys(obj, [ + {name: "ACCT", + valueTest: function(s) { return /^\d{15,16}$/.test(s) }, + valueReplace: replaceWithX}, + {name: "CVV2", + valueTest: function(s) { return /^\d{3,4}$/.test(s) }, + valueReplace: replaceWithX}])); +} + +var _USER = function() { return appjet.config['etherpad.paypal.user'] || "zamfir_1239051855_biz_api1.gmail.com"; } +var _PWD = function() { return appjet.config['etherpad.paypal.pwd'] || "1239051867"; } +var _SIGNATURE = function() { return appjet.config['etherpad.paypal.signature'] || "AQU0e5vuZCvSg-XJploSa.sGUDlpAwAy5fz.FhtfOQ25Qa9sFLDt7Bmp"; } +var _RECEIVER = function() { return appjet.config['etherpad.paypal.receiver'] || "zamfir_1239051855_biz@gmail.com"; } +var _paypalApiUrl = function() { return appjet.config['etherpad.paypal.apiUrl'] || "https://api-3t.sandbox.paypal.com/nvp"; } +var _paypalWebUrl = function() { return appjet.config['etherpad.paypal.webUrl'] || "https://www.sandbox.paypal.com/cgi-bin/webscr"; } +function paypalPurchaseUrl(token) { + return (appjet.config['etherpad.paypal.purchaseUrl'] || "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=")+token; +} + +function getPurchase(id) { + return sqlobj.selectSingle('billing_purchase', {id: id}); +} + +function getPurchaseForCustomer(customerId) { + return sqlobj.selectSingle('billing_purchase', {customer: customerId}); +} + +function updatePurchase(id, fields) { + sqlobj.updateSingle('billing_purchase', {id: id}, fields); +} + +function getInvoicesForPurchase(purchaseId) { + return sqlobj.selectMulti('billing_invoice', {purchase: purchaseId}); +} + +function getInvoice(id) { + return sqlobj.selectSingle('billing_invoice', {id: id}); +} + +function createInvoice() { + return _newInvoice(); +} + +function updateInvoice(id, fields) { + sqlobj.updateSingle('billing_invoice', {id: id}, fields) +} + +function getTransaction(id) { + return sqlobj.selectSingle('billing_transaction', {id: id}); +} +function getTransactionByExternalId(txnId) { + return sqlobj.selectSingle('billing_transaction', {txnId: txnId}); +} + +function getTransactionsForCustomer(customerId) { + return sqlobj.selectMulti('billing_transaction', {customer: customerId}); +} + +function getPendingTransactionsForCustomer(customerId) { + return sqlobj.selectMulti('billing_transaction', {customer: customerId, status: 'pending'}); +} + +function _updateTransaction(id, fields) { + return sqlobj.updateSingle('billing_transaction', {id: id}, fields); +} + +function getAdjustments(invoiceId) { + return sqlobj.selectMulti('billing_adjustment', {invoice: invoiceId}); +} + +function createSubscription(customer, product, dollars, couponCode) { + var purchaseId = _newPurchase(customer, product, dollarsToCents(dollars), couponCode); + _purchaseActive(purchaseId); + updatePurchase(purchaseId, {type: 'subscription', paidThrough: nextMonth(noon(new Date))}); + return purchaseId; +} + +function _newPurchase(customer, product, cents, couponCode) { + var purchaseId = sqlobj.insert('billing_purchase', { + customer: customer, + product: product, + cost: cents, + coupon: couponCode, + status: 'inactive' + }); + return purchaseId; +} + +function _newInvoice() { + var invoiceId = sqlobj.insert('billing_invoice', { + time: new Date(), + purchase: -1, + amt: 0, + status: 'pending' + }); + return invoiceId; +} + +function _newTransaction(customer, cents) { + var transactionId = sqlobj.insert('billing_transaction', { + customer: customer, + time: new Date(), + amt: cents, + status: 'new' + }); + return transactionId; +} + +function _newAdjustment(transaction, invoice, cents) { + sqlobj.insert('billing_adjustment', { + transaction: transaction, + invoice: invoice, + time: new Date(), + amt: cents + }); +} + +function _transactionSuccess(transaction, txnId, payInfo) { + _updateTransaction(transaction, { + status: 'success', txnId: txnId, time: new Date(), payInfo: payInfo + }); +} + +function _transactionFailure(transaction, txnId) { + _updateTransaction(transaction, { + status: 'failure', txnId: txnId, time: new Date() + }); +} + +function _transactionPending(transaction, txnId) { + _updateTransaction(transaction, { + status: 'pending', txnId: txnId, time: new Date() + }); +} + +function _invoicePaid(invoice) { + updateInvoice(invoice, {status: 'paid'}); +} + +function _purchaseActive(purchase) { + updatePurchase(purchase, {status: 'active'}); +} + +function _purchaseExtend(purchase, monthCount) { + var expiration = getPurchase(purchase).paidThrough; + for (var i = monthCount; i > 0; i--) { + expiration = nextMonth(expiration); + } + // paying your invoice always makes you current. + if (expiration < new Date) { + expiration = nextMonth(new Date); + } + updatePurchase(purchase, {paidThrough: expiration}); +} + +function _doPost(url, body) { + try { + var ret = urlPost(url, body); + } catch (e) { + if (e.javaException) { + net.appjet.oui.exceptionlog.apply(e.javaException); + } + return { error: e }; + } + return { value: ret }; +} + +function _doPaypalNvpPost(properties0) { + return { + status: 'failure', + errorMessage: "Billing has been discontinued. No new services may be purchased." + } + // var properties = { + // USER: _USER(), + // PWD: _PWD(), + // SIGNATURE: _SIGNATURE(), + // VERSION: "56.0" + // } + // eachProperty(properties0, function(k, v) { + // if (v !== undefined) { + // properties[k] = v; + // } + // }) + // log({'type': 'api call', 'value': properties}); + // var ret = _doPost(_paypalApiUrl(), properties); + // if (ret.error) { + // return { + // status: 'failure', + // exception: ret.error.javaException || ret.error, + // errorMessage: ret.error.message + // } + // } + // ret = ret.value; + // var paypalResponse = {}; + // ret.content.split("&").forEach(function(x) { + // var parts = x.split("="); + // paypalResponse[decodeURIComponent(parts[0])] = + // decodeURIComponent(parts[1]); + // }) + // + // var res = paypalResponse; + // log(res) + // if (res.ACK == "Success" || res.ACK == "SuccessWithWarning") { + // return { + // status: 'success', + // response: res + // } + // } else { + // errors = []; + // for (var i = 0; res['L_LONGMESSAGE'+i]; ++i) { + // errors.push(res['L_LONGMESSAGE'+i]); + // } + // return { + // status: 'failure', + // errorMessage: errors.join(", "), + // errorMessages: errors, + // response: res + // } + // } +} + +// status -> 'completion', 'bad', 'redundant', 'possible_fraud' +function handlePaypalNotification() { + var content = (typeof(request.content) == 'string' ? request.content : undefined); + if (! content) { + return new BillingResult('bad', "no content"); + } + log({'type': 'paypal-notification', 'content': content}); + var params = {}; + content.split("&").forEach(function(x) { + var parts = x.split("="); + params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]); + }); + var txnId = params.txn_id; + var properties = []; + for(var i in params) { + properties.push(i+" -> "+params[i]); + } + var debugString = properties.join(", "); + log({'type': 'parsed-paypal-notification', 'value': debugString}); + var transaction = getTransactionByExternalId(txnId); + log({'type': 'notification-transaction', 'value': (transaction || {})}); + if (_RECEIVER() != params.receiver_email) { + return new BillingResult('possible_fraud', debugString); + } + if (params.payment_status == "Completed" && transaction && + (transaction.status == 'pending' || transaction.status == 'new')) { + var ret = _doPost(_paypalWebUrl(), "cmd=_notify-validate&"+content); + if (ret.error || ret.value.content != "VERIFIED") { + return new BillingResult('possible_fraud', debugString); + } + var invoice = getInvoice(params.invoice); + if (invoice.amt != dollarsToCents(params.mc_gross)) { + return new BillingResult('possible_fraud', debugString); + } + + sqlcommon.inTransaction(function () { + _transactionSuccess(transaction.id, txnId, "via eCheck"); + _invoicePaid(invoice.id); + _purchaseActive(invoice.purchase); + }); + var purchase = getPurchase(invoice.purchase); + return new BillingResult('completion', debugString, null, + new PurchaseInfo(params.custom, + invoice.id, + transaction.id, + params.txn_id, + purchase.id, + centsToDollars(invoice.amt), + purchase.couponCode, + purchase.time, + undefined)); + } else { + return new BillingResult('redundant', debugString); + } +} + +function _expressCheckoutCustom(invoiceId, transactionId) { + return md5("zimki_sucks"+invoiceId+transactionId); +} + +function PurchaseInfo(custom, invoiceId, transactionId, paypalId, purchaseId, dollars, couponCode, time, token, description) { + this.__defineGetter__("custom", function() { return custom }); + this.__defineGetter__("invoiceId", function() { return invoiceId }); + this.__defineGetter__("transactionId", function() { return transactionId }); + this.__defineGetter__("paypalId", function() { return paypalId }); + this.__defineGetter__("purchaseId", function() { return purchaseId }); + this.__defineGetter__("cost", function() { return dollars }); + this.__defineGetter__("couponCode", function() { return couponCode }); + this.__defineGetter__("time", function() { return time }); + this.__defineGetter__("token", function() { return token }); + this.__defineGetter__("description", function() { return description }); +} + +function PayerInfo(paypalResult) { + this.__defineGetter__("payerId", function() { return paypalResult.response.PAYERID }); + this.__defineGetter__("email", function() { return paypalResult.response.EMAIL }); + this.__defineGetter__("businessName", function() { return paypalResult.response.BUSINESS }); + this.__defineGetter__("nameSalutation", function() { return paypalResult.response.SALUTATION }); + this.__defineGetter__("nameFirst", function() { return paypalResult.response.FIRSTNAME }); + this.__defineGetter__("nameMiddle", function() { return paypalResult.response.MIDDLENAME }); + this.__defineGetter__("nameLast", function() { return paypalResult.response.LASTNAME }); +} + +function BillingResult(status, debug, errorField, purchaseInfo, payerInfo) { + this.__defineGetter__("status", function() { return status }); + this.__defineGetter__("debug", function() { return debug }); + this.__defineGetter__("errorField", function() { return errorField }); + this.__defineGetter__("purchaseInfo", function() { return purchaseInfo }); + this.__defineGetter__("payerInfo", function() { return payerInfo }); +} + +function dollarsToCents(dollars) { + return Math.round(Number(dollars)*100); +} + +function centsToDollars(cents) { + return Math.round(Number(cents)) / 100; +} + +function verifyDollars(dollars) { + return Math.round(Number(dollars)*100)/100; +} + +function beginExpressPurchase(invoiceId, customerId, productId, dollars, couponCode, successUrl, failureUrl, notifyUrl, authorizeOnly) { + var cents = dollarsToCents(dollars); + var time = new Date(); + var purchaseId; + var transactionid; + if (! authorizeOnly) { + try { + sqlcommon.inTransaction(function() { + purchaseId = _newPurchase(customerId, productId, cents, couponCode); + updateInvoice(invoiceId, {purchase: purchaseId, amt: cents}); + transactionId = _newTransaction(customerId, cents); + _newAdjustment(transactionId, invoiceId, cents); + }); + } catch (e) { + if (e instanceof BillingResult) { return e; } + throw e; + } + } + + var paypalResult = + _setExpressCheckout(invoiceId, transactionId, cents, + successUrl, failureUrl, notifyUrl, authorizeOnly); + + if (paypalResult.status == 'success') { + var token = paypalResult.response.TOKEN; + return new BillingResult('success', paypalResult, null, new PurchaseInfo( + _expressCheckoutCustom(invoiceId, transactionId), + invoiceId, + transactionId, + undefined, + purchaseId, + verifyDollars(dollars), + couponCode, + time, + token)); + } else { + return new BillingResult('failure', paypalResult); + } +} + +function _setExpressCheckout(invoiceId, transactionId, cents, successUrl, failureUrl, notifyUrl, authorizeOnly) { + var properties = { + INVNUM: invoiceId, + + METHOD: 'SetExpressCheckout', + CUSTOM: + _expressCheckoutCustom(invoiceId, transactionId), + MAXAMT: centsToDollars(cents), + RETURNURL: successUrl, + CANCELURL: failureUrl, + NOTIFYURL: notifyUrl, + NOSHIPPING: 1, + PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'), + + AMT: centsToDollars(cents) + } + + return _doPaypalNvpPost(properties); +} + +function continueExpressPurchase(purchaseInfo, authorizeOnly) { + var paypalResult = _getExpressCheckoutDetails(purchaseInfo.token, authorizeOnly) + if (paypalResult.status == 'success') { + if (! authorizeOnly) { + if (paypalResult.response.INVNUM != purchaseInfo.invoiceId) { + return new BillingResult('failure', "invoice id mismatch"); + } + } + if (paypalResult.response.CUSTOM != + _expressCheckoutCustom(purchaseInfo.invoiceId, purchaseInfo.transactionId)) { + return new BillingResult('failure', "custom mismatch"); + } + return new BillingResult('success', paypalResult, null, null, new PayerInfo(paypalResult)); + } else { + return new BillingResult('failure', paypalResult); + } +} + +function _getExpressCheckoutDetails(token, authorizeOnly) { + var properties = { + METHOD: 'GetExpresscheckoutDetails', + TOKEN: token, + } + + return _doPaypalNvpPost(properties); +} + +function completeExpressPurchase(purchaseInfo, payerInfo, notifyUrl, authorizeOnly) { + var paypalResult = _doExpressCheckoutPayment(purchaseInfo, payerInfo, notifyUrl, authorizeOnly); + + if (paypalResult.status == 'success') { + if (paypalResult.response.PAYMENTSTATUS == 'Completed') { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionSuccess(purchaseInfo.transactionId, + paypalResult.response.TRANSACTIONID, "via PayPal"); + _invoicePaid(purchaseInfo.invoiceId); + _purchaseActive(purchaseInfo.purchaseId); + }); + } + return new BillingResult('success', paypalResult); + } else if (paypalResult.response.PAYMENTSTATUS == 'Pending') { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionPending(purchaseInfo.transactionId, + paypalResult.response.TRANSACTIONID); + }); + } + return new BillingResult('pending', paypalResult); + } + } else { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionFailure(purchaseInfo.transactionId, + (paypalResult.response ? + paypalResult.response.TRANSACTIONID || "" : + "")); + }); + } + return new BillingResult('failure', paypalResult); + } +} + +function _doExpressCheckoutPayment(purchaseInfo, payerInfo, notifyUrl, authorizeOnly) { + var properties = { + METHOD: 'DoExpressCheckoutPayment', + TOKEN: purchaseInfo.token, + PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'), + + NOTIFYURL: notifyUrl, + + PAYERID: payerInfo.payerId, + + AMT: verifyDollars(purchaseInfo.cost), // dollars + INVNUM: purchaseInfo.invoiceId, + CUSTOM: + _expressCheckoutCustom(purchaseInfo.invoiceId, purchaseInfo.transactionId) + } + + return _doPaypalNvpPost(properties); +} + +// which field has error? and, is it not user-correctable? +var _directErrorCodes = { + '10502': ['cardExpiration'], + '10504': ['cardCvv'], + '10505': ['addressStreet', true], + '10508': ['cardExpiration'], + '10510': ['cardType'], + '10512': ['nameFirst'], + '10513': ['nameLast'], + '10519': ['cardNumber'], + '10521': ['cardNumber'], + '10527': ['cardNumber'], + '10534': ['cardNumber', true], + '10535': ['cardNumber'], + '10536': ['invoiceId', true], + '10537': ['addressCountry', true], + '10540': ['addressStreet', true], + '10541': ['cardNumber', true], + '10554': ['address', true], + '10555': ['address', true], + '10556': ['address', true], + '10561': ['address'], + '10562': ['cardExpiration'], + '10563': ['cardExpiration'], + '10565': ['addressCountry'], + '10566': ['cardType'], + '10571': ['cardCvv'], + '10701': ['address'], + '10702': ['addressStreet'], + '10703': ['addressStreet2'], + '10704': ['addressCity'], + '10705': ['addressState'], + '10706': ['addressZip'], + '10707': ['addressCountry'], + '10708': ['address'], + '10709': ['addressStreet'], + '10710': ['addressCity'], + '10711': ['addressState'], + '10712': ['addressZip'], + '10713': ['addressCountry'], + '10714': ['address'], + '10715': ['addressState'], + '10716': ['addressZip'], + '10717': ['addressZip'], + '10718': ['addressCity,addressState'], + '10748': ['cardCvv'], + '10752': ['card'], + '10756': ['address,card'], + '10759': ['cardNumber'], + '10762': ['cardCvv'], + '11611': function(response) { + var avsCode = response.AVSCODE; + var cvv2Match = response.CVV2MATCH; + var errorFields = []; + switch (avsCode) { + case 'N': case 'C': case 'A': case 'B': + case 'R': case 'S': case 'U': case 'G': + case 'I': case 'E': + errorFields.push('address'); + } + switch (cvv2Match) { + case 'N': + errorFields.push('cardCvv'); + } + return [errorFields.join(",")]; + }, + '15004': ['cardCvv'], + '15005': ['cardNumber'], + '15006': ['cardNumber'], + '15007': ['cardNumber'] +} + +function authorizePurchase(payinfo, notifyUrl) { + return directPurchase(undefined, undefined, undefined, 1, undefined, payinfo, notifyUrl, true); +} + +function directPurchase(invoiceId, customerId, productId, dollars, couponCode, payinfo, notifyUrl, authorizeOnly) { + var time = new Date(); + var cents = dollarsToCents(dollars); + + var purchaseId, transactionId; + + if (! authorizeOnly) { + try { + sqlcommon.inTransaction(function() { + purchaseId = _newPurchase(customerId, productId, cents, couponCode); + updateInvoice(invoiceId, {purchase: purchaseId, amt: cents}); + transactionId = _newTransaction(customerId, cents); + _newAdjustment(transactionId, invoiceId, cents); + }); + } catch (e) { + if (e instanceof BillingResult) { return e; } + if (e.javaException || e.rhinoException) { + throw e.javaException || e.rhinoException; + } + throw e; + } + } + + var paypalResult = _doDirectPurchase(invoiceId, cents, payinfo, notifyUrl, authorizeOnly); + + if (paypalResult.status == 'success') { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionSuccess(transactionId, + paypalResult.response.TRANSACTIONID, + payinfo.cardType+" ending in "+payinfo.cardNumber.substr(-4)); + _invoicePaid(invoiceId); + _purchaseActive(purchaseId); + }); + } + return new BillingResult('success', paypalResult, null, new PurchaseInfo( + undefined, + invoiceId, + transactionId, + paypalResult.response.TRANSACTIONID, + purchaseId, + verifyDollars(dollars), + couponCode, + time, + undefined)); + } else { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionFailure(transactionId, + (paypalResult.response ? + paypalResult.response.TRANSACTIONID || "": + "")); + }); + } + return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response)); + } +} + +function _getErrorCodes(paypalResponse) { + var errorCodes = {userErrors: [], permanentErrors: []}; + if (! paypalResponse) { + return undefined; + } + for (var i = 0; paypalResponse['L_ERRORCODE'+i]; ++i) { + var code = paypalResponse['L_ERRORCODE'+i]; + var errorField = _directErrorCodes[code]; + if (typeof(errorField) == 'function') { + errorField = errorField(paypalResponse); + } + if (errorField && errorField[1]) { + Array.prototype.push.apply(errorCodes.permanentErrors, errorField[0].split(",")); + } else if (errorField) { + Array.prototype.push.apply(errorCodes.userErrors, errorField[0].split(",")); + } + } + return errorCodes; +} + +function _doDirectPurchase(invoiceId, cents, payinfo, notifyUrl, authorizeOnly) { + var properties = { + INVNUM: invoiceId, + + METHOD: 'DoDirectPayment', + PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'), + IPADDRESS: request.clientAddr, + NOTIFYURL: notifyUrl, + + CREDITCARDTYPE: payinfo.cardType, + ACCT: payinfo.cardNumber, + EXPDATE: payinfo.cardExpiration, + CVV2: payinfo.cardCvv, + + SALUTATION: payinfo.nameSalutation, + FIRSTNAME: payinfo.nameFirst, + MIDDLENAME: payinfo.nameMiddle, + LASTNAME: payinfo.nameLast, + SUFFIX: payinfo.nameSuffix, + + STREET: payinfo.addressStreet, + STREET2: payinfo.addressStreet2, + CITY: payinfo.addressCity, + STATE: payinfo.addressState, + COUNTRYCODE: payinfo.addressCountry, + ZIP: payinfo.addressZip, + + AMT: centsToDollars(cents) + } + + return _doPaypalNvpPost(properties); +} + +// function directAuthorization(payInfo, dollars, notifyUrl) { +// var paypalResult = _doDirectPurchase(undefined, dollarsToCents(dollars), payInfo, notifyUrl, true); +// if (paypalResult.status == 'success') { +// return new BillingResult('success', paypalResult, null, new PurchaseInfo( +// undefined, +// undefined, +// paypalResult.response.TRANSACTIONID, +// undefined, +// verifyDollars(dollars), +// undefined, +// undefined, +// undefined)); +// } else { +// return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response)); +// } +// } + +function asyncRecurringPurchase(invoiceId, purchaseId, oldTransactionId, paymentInfo, dollars, monthCount, notifyUrl) { + var time = new Date(); + var cents = dollarsToCents(dollars); + + var purchase, transactionId; + + try { + sqlcommon.inTransaction(function() { + // purchaseId = _newPurchase(customerId, productId, cents, couponCode); + purchase = getPurchase(purchaseId); + updateInvoice(invoiceId, {purchase: purchaseId, amt: cents}); + transactionId = _newTransaction(purchase.customer, cents); + _newAdjustment(transactionId, invoiceId, cents); + }); + } catch (e) { + if (e instanceof BillingResult) { return e; } + if (e.rhinoException) { + throw e.rhinoException; + } + throw e; + } + + // do transaction using previous transaction as template + var paypalResult; + if (cents == 0) { + // can't actually charge nothing, so fake it. + paypalResult = { status: 'success', response: { TRANSACTIONID: null }} + } else { + paypalResult = _doReferenceTransaction(invoiceId, cents, oldTransactionId, notifyUrl); + } + + if (paypalResult.status == 'success') { + sqlcommon.inTransaction(function() { + _transactionSuccess(transactionId, + paypalResult.response.TRANSACTIONID, + paymentInfo); + _invoicePaid(invoiceId); + _purchaseActive(purchaseId); + _purchaseExtend(purchaseId, monthCount); + }); + return new BillingResult('success', paypalResult, null, new PurchaseInfo( + undefined, + invoiceId, + transactionId, + paypalResult.response.TRANSACTIONID, + purchaseId, + verifyDollars(dollars), + undefined, + time, + undefined)); + } else { + sqlcommon.inTransaction(function() { + _transactionFailure(transactionId, + (paypalResult.response ? + paypalResult.response.TRANSACTIONID || "": + "")); + }); + return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response)); + } +} + +function _doReferenceTransaction(invoiceId, cents, transactionId, notifyUrl) { + var properties = { + METHOD: 'DoReferenceTransaction', + PAYMENTACTION: 'Sale', + + REFERENCEID: transactionId, + AMT: centsToDollars(cents), + INVNUM: invoiceId + } + + return _doPaypalNvpPost(properties); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/billing/fields.js b/trunk/etherpad/src/etherpad/billing/fields.js new file mode 100644 index 0000000..4a307ac --- /dev/null +++ b/trunk/etherpad/src/etherpad/billing/fields.js @@ -0,0 +1,219 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +// Taken from paypal's form +var countryList = [ + ["US", "United States"], + ["AL", "Albania"], + ["DZ", "Algeria"], + ["AD", "Andorra"], + ["AO", "Angola"], + ["AI", "Anguilla"], + ["AG", "Antigua and Barbuda"], + ["AR", "Argentina"], + ["AM", "Armenia"], + ["AW", "Aruba"], + ["AU", "Australia"], + ["AT", "Austria"], + ["AZ", "Azerbaijan Republic"], + ["BS", "Bahamas"], + ["BH", "Bahrain"], + ["BB", "Barbados"], + ["BE", "Belgium"], + ["BZ", "Belize"], + ["BJ", "Benin"], + ["BM", "Bermuda"], + ["BT", "Bhutan"], + ["BO", "Bolivia"], + ["BA", "Bosnia and Herzegovina"], + ["BW", "Botswana"], + ["BR", "Brazil"], + ["VG", "British Virgin Islands"], + ["BN", "Brunei"], + ["BG", "Bulgaria"], + ["BF", "Burkina Faso"], + ["BI", "Burundi"], + ["KH", "Cambodia"], + ["CA", "Canada"], + ["CV", "Cape Verde"], + ["KY", "Cayman Islands"], + ["TD", "Chad"], + ["CL", "Chile"], + ["C2", "China"], + ["CO", "Colombia"], + ["KM", "Comoros"], + ["CK", "Cook Islands"], + ["CR", "Costa Rica"], + ["HR", "Croatia"], + ["CY", "Cyprus"], + ["CZ", "Czech Republic"], + ["CD", "Democratic Republic of the Congo"], + ["DK", "Denmark"], + ["DJ", "Djibouti"], + ["DM", "Dominica"], + ["DO", "Dominican Republic"], + ["EC", "Ecuador"], + ["SV", "El Salvador"], + ["ER", "Eritrea"], + ["EE", "Estonia"], + ["ET", "Ethiopia"], + ["FK", "Falkland Islands"], + ["FO", "Faroe Islands"], + ["FM", "Federated States of Micronesia"], + ["FJ", "Fiji"], + ["FI", "Finland"], + ["FR", "France"], + ["GF", "French Guiana"], + ["PF", "French Polynesia"], + ["GA", "Gabon Republic"], + ["GM", "Gambia"], + ["DE", "Germany"], + ["GI", "Gibraltar"], + ["GR", "Greece"], + ["GL", "Greenland"], + ["GD", "Grenada"], + ["GP", "Guadeloupe"], + ["GT", "Guatemala"], + ["GN", "Guinea"], + ["GW", "Guinea Bissau"], + ["GY", "Guyana"], + ["HN", "Honduras"], + ["HK", "Hong Kong"], + ["HU", "Hungary"], + ["IS", "Iceland"], + ["IN", "India"], + ["ID", "Indonesia"], + ["IE", "Ireland"], + ["IL", "Israel"], + ["IT", "Italy"], + ["JM", "Jamaica"], + ["JP", "Japan"], + ["JO", "Jordan"], + ["KZ", "Kazakhstan"], + ["KE", "Kenya"], + ["KI", "Kiribati"], + ["KW", "Kuwait"], + ["KG", "Kyrgyzstan"], + ["LA", "Laos"], + ["LV", "Latvia"], + ["LS", "Lesotho"], + ["LI", "Liechtenstein"], + ["LT", "Lithuania"], + ["LU", "Luxembourg"], + ["MG", "Madagascar"], + ["MW", "Malawi"], + ["MY", "Malaysia"], + ["MV", "Maldives"], + ["ML", "Mali"], + ["MT", "Malta"], + ["MH", "Marshall Islands"], + ["MQ", "Martinique"], + ["MR", "Mauritania"], + ["MU", "Mauritius"], + ["YT", "Mayotte"], + ["MX", "Mexico"], + ["MN", "Mongolia"], + ["MS", "Montserrat"], + ["MA", "Morocco"], + ["MZ", "Mozambique"], + ["NA", "Namibia"], + ["NR", "Nauru"], + ["NP", "Nepal"], + ["NL", "Netherlands"], + ["AN", "Netherlands Antilles"], + ["NC", "New Caledonia"], + ["NZ", "New Zealand"], + ["NI", "Nicaragua"], + ["NE", "Niger"], + ["NU", "Niue"], + ["NF", "Norfolk Island"], + ["NO", "Norway"], + ["OM", "Oman"], + ["PW", "Palau"], + ["PA", "Panama"], + ["PG", "Papua New Guinea"], + ["PE", "Peru"], + ["PN", "Pitcairn Islands"], + ["PL", "Poland"], + ["PT", "Portugal"], + ["QA", "Qatar"], + ["CG", "Republic of the Congo"], + ["RE", "Reunion"], + ["RO", "Romania"], + ["RU", "Russia"], + ["VC", "Saint Vincent and the Grenadines"], + ["WS", "Samoa"], + ["SM", "San Marino"], + ["ST", "São Tomé and Príncipe"], + ["SA", "Saudi Arabia"], + ["SN", "Senegal"], + ["SC", "Seychelles"], + ["SL", "Sierra Leone"], + ["SG", "Singapore"], + ["SK", "Slovakia"], + ["SI", "Slovenia"], + ["SB", "Solomon Islands"], + ["SO", "Somalia"], + ["ZA", "South Africa"], + ["KR", "South Korea"], + ["ES", "Spain"], + ["LK", "Sri Lanka"], + ["SH", "St. Helena"], + ["KN", "St. Kitts and Nevis"], + ["LC", "St. Lucia"], + ["PM", "St. Pierre and Miquelon"], + ["SR", "Suriname"], + ["SJ", "Svalbard and Jan Mayen Islands"], + ["SZ", "Swaziland"], + ["SE", "Sweden"], + ["CH", "Switzerland"], + ["TW", "Taiwan"], + ["TJ", "Tajikistan"], + ["TZ", "Tanzania"], + ["TH", "Thailand"], + ["TG", "Togo"], + ["TO", "Tonga"], + ["TT", "Trinidad and Tobago"], + ["TN", "Tunisia"], + ["TR", "Turkey"], + ["TM", "Turkmenistan"], + ["TC", "Turks and Caicos Islands"], + ["TV", "Tuvalu"], + ["UG", "Uganda"], + ["UA", "Ukraine"], + ["AE", "United Arab Emirates"], + ["GB", "United Kingdom"], + ["UY", "Uruguay"], + ["VU", "Vanuatu"], + ["VA", "Vatican City State"], + ["VE", "Venezuela"], + ["VN", "Vietnam"], + ["WF", "Wallis and Futuna Islands"], + ["YE", "Yemen"], + ["ZM", "Zambia"], +]; + +var usaStateList = [ + "", "AK", "AL", "AR", "AZ", "CA", "CO", "CT", "DC", "DE", + "FL", "GA", "HI", "IA", "ID", "IL", "IN", "KS", "KY", "LA", + "MA", "MD", "ME", "MI", "MN", "MO", "MS", "MT", "NC", "ND", + "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA", + "RI", "SC", "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI", + "WV", "WY", "AA", "AE", "AP", "AS", "FM", "GU", "MH", "MP", + "PR", "PW", "VI" +]; + diff --git a/trunk/etherpad/src/etherpad/billing/team_billing.js b/trunk/etherpad/src/etherpad/billing/team_billing.js new file mode 100644 index 0000000..ae8ae8a --- /dev/null +++ b/trunk/etherpad/src/etherpad/billing/team_billing.js @@ -0,0 +1,422 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("execution"); +import("exceptionutils"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon.inTransaction"); + +import("etherpad.billing.billing"); +import("etherpad.globals"); +import("etherpad.log"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_quotas"); +import("etherpad.store.checkout"); +import("etherpad.utils.renderTemplateAsString"); + +jimport("java.lang.System.out.println"); + +function recurringBillingNotifyUrl() { + return ""; +} + +function _billing() { + if (! appjet.cache.billing) { + appjet.cache.billing = {}; + } + return appjet.cache.billing; +} + +function _lpad(str, width, padDigit) { + str = String(str); + padDigit = (padDigit === undefined ? ' ' : padDigit); + var count = width - str.length; + var prepend = [] + for (var i = 0; i < count; ++i) { + prepend.push(padDigit); + } + return prepend.join("")+str; +} + +// utility functions + +function _dayToDateTime(date) { + return [date.getFullYear(), _lpad(date.getMonth()+1, 2, '0'), _lpad(date.getDate(), 2, '0')].join("-"); +} + +function _createInvoice(subscription) { + var maxUsers = getMaxUsers(subscription.customer); + var invoice = inTransaction(function() { + var invoiceId = billing.createInvoice(); + billing.updateInvoice( + invoiceId, + {purchase: subscription.id, + amt: billing.dollarsToCents(calculateSubscriptionCost(maxUsers, subscription.coupon)), + users: maxUsers}); + return billing.getInvoice(invoiceId); + }); + if (invoice) { + resetMaxUsers(subscription.customer) + } + return invoice; +} + +function getExpiredSubscriptions(date) { + return sqlobj.selectMulti('billing_purchase', + {type: 'subscription', + status: 'active', + paidThrough: ['<', _dayToDateTime(date)]}); +} + +function getAllSubscriptions() { + return sqlobj.selectMulti('billing_purchase', {type: 'subscription', status: 'active'}); +} + +function getSubscriptionForCustomer(customerId) { + return sqlobj.selectSingle('billing_purchase', + {type: 'subscription', + customer: customerId}); +} + +function getOrCreateInvoice(subscription) { + return inTransaction(function() { + var existingInvoice = + sqlobj.selectSingle('billing_invoice', + {purchase: subscription.id, status: 'pending'}); + if (existingInvoice) { + return existingInvoice; + } else { + return _createInvoice(subscription); + } + }); +} + +function getLatestPendingInvoice(subscriptionId) { + return sqlobj.selectMulti('billing_invoice', + {purchase: subscriptionId, status: 'pending'}, + {orderBy: '-time', limit: 1})[0]; +} + +function getLatestPaidInvoice(subscriptionId) { + return sqlobj.selectMulti('billing_invoice', + {purchase: subscriptionId, status: 'paid'}, + {orderBy: '-time', limit: 1})[0]; +} + +function pendingTransactions(customer) { + return billing.getPendingTransactionsForCustomer(customer); +} + +function checkPendingTransactions(transactions) { + // XXX: do nothing for now. + return transactions.length > 0; +} + +function getRecurringBillingTransactionId(customerId) { + return sqlobj.selectSingle('billing_payment_info', {customer: customerId}).transaction; +} + +function getRecurringBillingInfo(customerId) { + return sqlobj.selectSingle('billing_payment_info', {customer: customerId}); +} + +function clearRecurringBillingInfo(customerId) { + return sqlobj.deleteRows('billing_payment_info', {customer: customerId}); +} + +function setRecurringBillingInfo(customerId, fullName, email, paymentSummary, expiration, transactionId) { + var info = { + fullname: fullName, + email: email, + paymentsummary: paymentSummary, + expiration: expiration, + transaction: transactionId + } + inTransaction(function() { + if (sqlobj.selectSingle('billing_payment_info', {customer: customerId})) { + sqlobj.update('billing_payment_info', {customer: customerId}, info); + } else { + info.customer = customerId; + sqlobj.insert('billing_payment_info', info); + } + }); +} + +function createSubscription(customerId, couponCode) { + domainCacheClear(customerId); + return inTransaction(function() { + return billing.createSubscription(customerId, 'ONDEMAND', 0, couponCode); + }); +} + +function updateSubscriptionCouponCode(subscriptionId, couponCode) { + billing.updatePurchase(subscriptionId, {coupon: couponCode || ""}); +} + +function subscriptionChargeFailure(subscription, invoice, failureMessage) { + billing.updatePurchase(subscription.id, + {error: failureMessage, status: 'inactive'}); + sendFailureEmail(subscription, invoice); +} + +function subscriptionChargeSuccess(subscription, invoice) { + sendReceiptEmail(subscription, invoice); +} + +function errorFieldsToMessage(errorCodes) { + var prefix = "Your payment information was rejected. Please verify your "; + var errorList = (errorCodes.permanentErrors ? errorCodes.permanentErrors : errorCodes.userErrors); + + return prefix + + errorList.map(function(field) { + return checkout.billingCartFieldMap[field].d; + }).join(", ")+ + "." +} + +function getAllInvoices(customer) { + var purchase = getSubscriptionForCustomer(customer); + if (! purchase) { + return []; + } + return billing.getInvoicesForPurchase(purchase.id); +} + +// scheduled charges + +function attemptCharge(invoice, subscription) { + var billingInfo = getRecurringBillingInfo(subscription.customer); + if (! billingInfo) { + subscriptionChargeFailure(subscription, invoice, "No billing information on file."); + return false; + } + + var result = + billing.asyncRecurringPurchase( + invoice.id, + subscription.id, + billingInfo.transaction, + billingInfo.paymentsummary, + billing.centsToDollars(invoice.amt), + 1, // 1 month only for now + recurringBillingNotifyUrl); + if (result.status == 'success') { + subscriptionChargeSuccess(subscription, invoice); + return true; + } else { + subscriptionChargeFailure(subscription, invoice, errorFieldsToMessage(result.errorField)); + return false; + } +} + +function processSubscription(subscription) { + try { + var hasPendingTransactions = inTransaction(function() { + var transactions = pendingTransactions(subscription.customer); + if (checkPendingTransactions(transactions)) { + billing.log({type: 'pending-transactions-delay', subscription: subscription, transactions: transactions}); + // there are actual pending transactions. wait until tomorrow. + return true; + } else { + return false; + } + }); + if (hasPendingTransactions) { + return; + } + var invoice = getOrCreateInvoice(subscription); + + return attemptCharge(invoice, subscription); + } catch (e) { + log.logException(e); + billing.log({message: "Thrown error", + exception: exceptionutils.getStackTracePlain(e), + subscription: subscription}); + subscriptionChargeFailure(subscription, "Permanent failure. Please confirm your billing information."); + } finally { + domainCacheClear(subscription.customer); + } +} + +function processAllSubscriptions() { + var subs = getExpiredSubscriptions(new Date); + println("processing "+subs.length+" subscriptions."); + subs.forEach(processSubscription); +} + +function _scheduleNextDailyUpdate() { + // Run at 2:22am every day + var now = +(new Date); + var tomorrow = new Date(now + 1000*60*60*24); + tomorrow.setHours(2); + tomorrow.setMinutes(22); + tomorrow.setMilliseconds(222); + log.info("Scheduling next daily billing update for: "+tomorrow.toString()); + var delay = +tomorrow - (+(new Date)); + execution.scheduleTask('billing', "billingDailyUpdate", delay, []); +} + +serverhandlers.tasks.billingDailyUpdate = function() { + return; // do nothing, there's no more billing. + // if (! globals.isProduction()) { return; } + // try { + // processAllSubscriptions(); + // } finally { + // _scheduleNextDailyUpdate(); + // } +} + +function onStartup() { + execution.initTaskThreadPool("billing", 1); + _scheduleNextDailyUpdate(); +} + +// pricing + +function getMaxUsers(customer) { + return pro_quotas.getAccountUsageCount(customer); +} + +function resetMaxUsers(customer) { + pro_quotas.resetAccountUsageCount(customer); +} + +var COST_PER_USER = 8; + +function getCouponValue(couponCode) { + if (couponCode && couponCode.length == 8) { + return sqlobj.selectSingle('checkout_pro_referral', {id: couponCode}); + } +} + +function calculateSubscriptionCost(users, couponId) { + if (users <= globals.PRO_FREE_ACCOUNTS) { + return 0; + } + var coupon = getCouponValue(couponId); + var pctDiscount = (coupon ? coupon.pctDiscount : 0); + var freeUsers = (coupon ? coupon.freeUsers : 0); + + var cost = (users - freeUsers) * COST_PER_USER; + cost = cost * (100-pctDiscount)/100; + + return Math.max(0, cost); +} + +// currentDomainsCache + +function _cache() { + if (! appjet.cache.currentDomainsCache) { + appjet.cache.currentDomainsCache = {}; + } + return appjet.cache.currentDomainsCache; +} + +function domainCacheClear(domain) { + delete _cache()[domain]; +} + +function _domainCacheGetOrUpdate(domain, f) { + if (domain in _cache()) { + return _cache()[domain]; + } + + _cache()[domain] = f(); + return _cache()[domain]; +} + +// external API helpers + +function _getPaidThroughDate(domainId) { + return _domainCacheGetOrUpdate(domainId, function() { + var subscription = getSubscriptionForCustomer(domainId); + if (! subscription) { + return null; + } else { + return subscription.paidThrough; + } + }); +} + +// external API + +var GRACE_PERIOD_DAYS = 10; + +var CURRENT = 0; +var PAST_DUE = 1; +var SUSPENDED = 2; +var NO_BILLING_INFO = 3; + +function getDomainStatus(domainId) { + var paidThrough = _getPaidThroughDate(domainId); + + if (paidThrough == null) { + return NO_BILLING_INFO; + } + if (paidThrough.getTime() > new Date(Date.now()-86400*1000)) { + return CURRENT; + } + // less than GRACE_PERIOD_DAYS have passed since paidThrough date + if (paidThrough.getTime() > Date.now() - GRACE_PERIOD_DAYS*86400*1000) { + return PAST_DUE; + } + return SUSPENDED; +} + +function getDomainDueDate(domainId) { + return _getPaidThroughDate(domainId); +} + +function getDomainSuspensionDate(domainId) { + return new Date(_getPaidThroughDate(domainId).getTime() + GRACE_PERIOD_DAYS*86400*1000); +} + +// emails + +function sendReceiptEmail(subscription, invoice) { + var paymentInfo = getRecurringBillingInfo(subscription.customer); + var coupon = getCouponValue(subscription.coupon); + var emailText = renderTemplateAsString('email/pro_payment_receipt.ejs', { + fullName: paymentInfo.fullname, + paymentSummary: paymentInfo.paymentsummary, + expiration: checkout.formatExpiration(paymentInfo.expiration), + invoiceNumber: invoice.id, + numUsers: invoice.users, + cost: billing.centsToDollars(invoice.amt), + dollars: checkout.dollars, + coupon: coupon, + globals: globals + }); + var address = paymentInfo.email; + checkout.salesEmail(address, "sales@pad.spline.inf.fu-berlin.de", "EtherPad: Receipt for "+paymentInfo.fullname, + {}, emailText); +} + +function sendFailureEmail(subscription, invoice, failureMessage) { + var domain = subscription.customer; + var subDomain = domains.getDomainRecord(domain).subDomain; + var paymentInfo = getRecurringBillingInfo(subscription.customer); + var emailText = renderTemplateAsString('email/pro_payment_failure.ejs', { + fullName: paymentInfo.fullname, + billingError: failureMessage, + balance: "US $"+checkout.dollars(billing.centsToDollars(invoice.amt)), + suspensionDate: checkout.formatDate(new Date(subscription.paidThrough.getTime()+GRACE_PERIOD_DAYS*86400*1000)), + billingAdminLink: "https://"+subDomain+".pad.spline.inf.fu-berlin.de/ep/admin/billing/" + }); + var address = paymentInfo.email; + checkout.salesEmail(address, "sales@pad.spline.inf.fu-berlin.de", "EtherPad: Payment Failure for "+paymentInfo.fullname, + {}, emailText); +}
\ No newline at end of file |