├── .github └── workflows │ └── test.yml ├── .gitignore ├── Makefile ├── README.md ├── lint_config.moon ├── payments-dev-1.rockspec ├── payments ├── amazon.lua ├── amazon.moon ├── base_client.lua ├── base_client.moon ├── paypal.lua ├── paypal.moon ├── paypal │ ├── adaptive.lua │ ├── adaptive.moon │ ├── express_checkout.lua │ ├── express_checkout.moon │ ├── helpers.lua │ ├── helpers.moon │ ├── rest.lua │ └── rest.moon ├── stripe.lua └── stripe.moon └── spec ├── helpers.moon ├── paypal ├── adaptive_spec.moon ├── express_spec.moon └── rest_spec.moon └── stripe_spec.moon /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | luaVersion: ["5.1", "5.2", "5.3", "luajit", "luajit-openresty"] 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@master 16 | 17 | - uses: leafo/gh-actions-lua@master 18 | with: 19 | luaVersion: ${{ matrix.luaVersion }} 20 | 21 | - uses: leafo/gh-actions-luarocks@master 22 | 23 | - name: build 24 | run: | 25 | luarocks install https://raw.githubusercontent.com/leafo/lua-cjson/master/lua-cjson-dev-1.rockspec 26 | luarocks install busted 27 | luarocks install moonscript 28 | luarocks make 29 | 30 | - name: test 31 | run: | 32 | busted -o utfTerminal 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lint_config.lua 2 | test.moon 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: local lint build 2 | 3 | local: build 4 | luarocks --lua-version=5.1 make --local payments-dev-1.rockspec 5 | 6 | build: 7 | moonc payments lint_config.moon 8 | 9 | lint: 10 | moonc -l payments 11 | 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Lua Payments 2 | 3 | ![test](https://github.com/leafo/lua-payments/workflows/test/badge.svg) 4 | 5 | Bindings to various payment provider APIs for use in Lua (with OpenResty via 6 | Lapis or anything that supports LuaSocket or 7 | [cqueues](http://www.25thandclement.com/~william/projects/cqueues.html) with 8 | [lua-http](https://github.com/daurnimator/lua-http)) 9 | 10 | The following APIs are supported: 11 | 12 | * [Stripe](#stripe) 13 | * [PayPal Express Checkout](#paypal-express-checkout) 14 | * [PayPal REST](#paypal-rest-api) 15 | * [PayPal Adaptive Payments](#paypal-adaptive-payments) 16 | 17 | ## Install 18 | 19 | luarocks install payments 20 | 21 | ## Examples 22 | 23 | 24 | ### PayPal Rest API 25 | 26 | Create the API client: 27 | 28 | ```lua 29 | local paypal = require("payments.paypal") 30 | 31 | local client = paypal.PayPalRest({ 32 | sandbox = true, 33 | client_id = "AVP_0123445", 34 | secret = "EFAAAAEFE-HELLO-WORLD", 35 | }) 36 | ``` 37 | 38 | Fetch some data: 39 | 40 | ```lua 41 | local payments = client:payment_resources() 42 | ``` 43 | 44 | Create a new payment: 45 | 46 | 47 | ```lua 48 | local res, status = client:create_payment({ 49 | intent = "sale", 50 | payer = { 51 | payment_method: "paypal" 52 | }, 53 | transactions = { 54 | { 55 | description = "My thinger", 56 | invoice_number = "P-1291829281", 57 | 58 | amount = { 59 | total = "5.99" 60 | currency = "USD" 61 | } 62 | } 63 | }, 64 | redirect_urls = { 65 | return_url = "http://example.com/confirm-payment", 66 | cancel_url = "http://example.com/cancel-payment" 67 | } 68 | }) 69 | ``` 70 | 71 | 72 | **Note:** This currently uses the PayPal V1 REST API. You can force calls to go 73 | to V2 by adjusting the following field on your client instance: 74 | 75 | ```lua 76 | client.api_version = "v2" 77 | client:create_checkout_order({...}) 78 | ``` 79 | 80 | 81 | ### Stripe 82 | 83 | Create the API client: 84 | 85 | ```lua 86 | 87 | local Stripe = require("payments.stripe").Stripe 88 | 89 | local client = Stripe({ 90 | client_id = "ca_12345", 91 | client_secret = "sk_test_helloworld", 92 | publishable_key = "pk_test_blahblahblahb" 93 | }) 94 | ``` 95 | 96 | Fetch some data: 97 | 98 | ```lua 99 | 100 | -- each resource exposed by Stripe API has a respective list_, get_, and each_ 101 | -- method in this library: 102 | 103 | local result = client:list_charges() 104 | local result = client:list_accounts({ limit = "100 "}) 105 | local result = client:list_disputes({ starting_after = "dsp_12343" }) 106 | 107 | -- get a single item 108 | local result = client:get_customer("cust_12o323480") 109 | 110 | -- iterate through every refund, fetching each page as needed 111 | for refund in client:each_refund() do 112 | print(refund.id) 113 | end 114 | ``` 115 | 116 | Create a charge: 117 | 118 | ```lua 119 | local result, err = client:charge({ 120 | card = "tok_232u302" 121 | amount = "5.99", 122 | currency = "USD", 123 | description = "indie games" 124 | }) 125 | ``` 126 | 127 | Resouces can be created, updated, and deleted: 128 | 129 | ```lua 130 | 131 | local customer = client:create_customer({ 132 | email = "loaf@itch.zone" 133 | }) 134 | 135 | client:update_customer(customer.id, { 136 | account_balance = 23023 137 | }) 138 | 139 | client:delete_customer(customer.id) 140 | 141 | ``` 142 | 143 | ### PayPal Express Checkout 144 | 145 | Create the API client: 146 | 147 | ```lua 148 | local paypal = require("payments.paypal") 149 | 150 | local client = paypal.PayPalExpressCheckout({ 151 | sandbox = true, 152 | auth = { 153 | USER = "me_1212121.leafo.net", 154 | PWD = "123456789", 155 | SIGNATURE = "AABBBC_CCZZZXXX" 156 | } 157 | }) 158 | ``` 159 | 160 | Create a new purchase page: 161 | 162 | ```lua 163 | local res = assert(client:set_express_checkout({ 164 | returnurl = "http://leafo.net/success", 165 | cancelurl = "http://leafo.net/cancel", 166 | brandname = "Purchase something", 167 | paymentrequest_0_amt = "$5.99" 168 | })) 169 | 170 | 171 | -- redirect the buyer to the payment page: 172 | print(client:checkout_url(res.TOKEN)) 173 | ``` 174 | 175 | 176 | ### PayPal Adaptive Payments 177 | 178 | > **Note:** This is a legacy API now deprecated by PayPal. You probably don't want to be using this. 179 | 180 | Create the API client: 181 | 182 | ```lua 183 | local paypal = require("payments.paypal") 184 | 185 | local client = paypal.PayPalAdaptive({ 186 | sandbox = true, 187 | application_id = "APP-1234HELLOWORLD", 188 | auth = { 189 | USER = "me_1212121.leafo.net", 190 | PWD = "123456789", 191 | SIGNATURE = "AABBBC_CCZZZXXX" 192 | } 193 | }) 194 | ``` 195 | 196 | Create a new purchase page: 197 | 198 | 199 | ```lua 200 | local res = assert(client:pay({ 201 | cancelUrl = "http://leafo.net/cancel", 202 | returnUrl = "http://leafo.net/return", 203 | currencyCode = "EUR", 204 | receivers = { 205 | { 206 | email = "me@example.com", 207 | amount = "5.50", 208 | primary = true, 209 | }, 210 | { 211 | email = "you@example.com", 212 | amount = "1.50", 213 | } 214 | } 215 | })) 216 | 217 | -- configure the checkout page 218 | assert(client:set_payment_options(res.payKey, { 219 | ["displayOptions.businessName"] = "My adaptive store front" 220 | })) 221 | 222 | -- redirect the buyer to the payment page: 223 | print(client:checkout_url(res.payKey)) 224 | 225 | -- after completion, you can check the payment status 226 | local details = assert(client:payment_details(res.payKey)) 227 | 228 | ``` 229 | 230 | 231 | ## HTTP Client 232 | 233 | All of the APIs exposed here are powered by HTTP. This client supports using 234 | different HTTP client libraries depending on the environment. 235 | 236 | If `ngx` is available in the global scope then Lapis' HTTP library is used by 237 | default. This will give you non-blocking requests within nginx. Otherwise, 238 | LuaSec and LuaSocket are used. 239 | 240 | You can manually set the client by passing a `http_provider` parameter to any 241 | of the client constructors. For example, to use cqueues pass 242 | `"http.compat.socket"` as the provider: 243 | 244 | ```lua 245 | local Stripe = require("payments.stripe").Stripe 246 | 247 | local client = Stripe({ 248 | http_provider = "http.compat.socket", 249 | -- ... 250 | }) 251 | ``` 252 | 253 | ## License 254 | 255 | MIT, Copyright (C) 2022 by Leaf Corcoran 256 | -------------------------------------------------------------------------------- /lint_config.moon: -------------------------------------------------------------------------------- 1 | { 2 | whitelist_globals: { 3 | ["."]: {"ngx"} 4 | } 5 | } 6 | 7 | -------------------------------------------------------------------------------- /payments-dev-1.rockspec: -------------------------------------------------------------------------------- 1 | package = "payments" 2 | version = "dev-1" 3 | 4 | source = { 5 | url = "git+https://github.com/leafo/lua-payments.git", 6 | } 7 | 8 | description = { 9 | summary = "Payment APIs for Lua, including Stripe & PayPal. Works with Openresty", 10 | homepage = "https://github.com/leafo/lua-payments", 11 | license = "MIT" 12 | } 13 | 14 | dependencies = { 15 | "lua >= 5.1", 16 | "lua-cjson", 17 | "luasocket", 18 | "luasec", 19 | "lapis", -- for encode_query_string 20 | "tableshape", 21 | } 22 | 23 | build = { 24 | type = "builtin", 25 | modules = { 26 | ["payments.amazon"] = "payments/amazon.lua", 27 | ["payments.base_client"] = "payments/base_client.lua", 28 | ["payments.paypal"] = "payments/paypal.lua", 29 | ["payments.paypal.adaptive"] = "payments/paypal/adaptive.lua", 30 | ["payments.paypal.express_checkout"] = "payments/paypal/express_checkout.lua", 31 | ["payments.paypal.helpers"] = "payments/paypal/helpers.lua", 32 | ["payments.paypal.rest"] = "payments/paypal/rest.lua", 33 | ["payments.stripe"] = "payments/stripe.lua", 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /payments/amazon.lua: -------------------------------------------------------------------------------- 1 | local debug = false 2 | local http = require("lapis.nginx.http") 3 | local ltn12 = require("ltn12") 4 | local encode_query_string, parse_query_string 5 | do 6 | local _obj_0 = require("lapis.util") 7 | encode_query_string, parse_query_string = _obj_0.encode_query_string, _obj_0.parse_query_string 8 | end 9 | local hmac_sha1, encode_base64, decode_base64 10 | do 11 | local _obj_0 = require("lapis.util.encoding") 12 | hmac_sha1, encode_base64, decode_base64 = _obj_0.hmac_sha1, _obj_0.encode_base64, _obj_0.decode_base64 13 | end 14 | local sort, concat 15 | do 16 | local _obj_0 = table 17 | sort, concat = _obj_0.sort, _obj_0.concat 18 | end 19 | local parse_url = require("socket.url").parse 20 | local extend 21 | extend = function(a, ...) 22 | local _list_0 = { 23 | ... 24 | } 25 | for _index_0 = 1, #_list_0 do 26 | local t = _list_0[_index_0] 27 | if t then 28 | for k, v in pairs(t) do 29 | a[k] = v 30 | end 31 | end 32 | end 33 | return a 34 | end 35 | local format_price 36 | format_price = function(cents) 37 | local dollars = math.floor(cents / 100) 38 | local change = ("%02d"):format(cents % 100) 39 | return tostring(dollars) .. "." .. tostring(change) 40 | end 41 | local valid_amount 42 | valid_amount = function(str) 43 | if str:match("%d+%.%d%d") then 44 | return true 45 | end 46 | return nil, "invalid amount (" .. tostring(str) .. ")" 47 | end 48 | local url_encode 49 | url_encode = function(str) 50 | return (str:gsub("[^a-zA-Z0-9_.~%-]", function(chr) 51 | local byte = chr:byte() 52 | local hex = ("%02x"):format(byte):upper() 53 | return "%" .. tostring(hex) 54 | end)) 55 | end 56 | local AmazonFPS 57 | do 58 | local _class_0 59 | local find_node, filter_nodes, node_value, extract_errors 60 | local _base_0 = { 61 | override_ipn = nil, 62 | format_price = function(self, ...) 63 | return format_price(...) 64 | end, 65 | sign_params = function(self, params, verb, host, path) 66 | if verb == nil then 67 | verb = "GET" 68 | end 69 | if host == nil then 70 | host = error("missing host") 71 | end 72 | if path == nil then 73 | path = error("missing path") 74 | end 75 | local query 76 | if type(params) == "table" then 77 | local tuples 78 | do 79 | local _accum_0 = { } 80 | local _len_0 = 1 81 | for k, v in pairs(params) do 82 | _accum_0[_len_0] = { 83 | k, 84 | v 85 | } 86 | _len_0 = _len_0 + 1 87 | end 88 | tuples = _accum_0 89 | end 90 | sort(tuples, function(left, right) 91 | return left[1] < right[1] 92 | end) 93 | query = concat((function() 94 | local _accum_0 = { } 95 | local _len_0 = 1 96 | for _index_0 = 1, #tuples do 97 | local t = tuples[_index_0] 98 | _accum_0[_len_0] = url_encode(t[1]) .. "=" .. url_encode(t[2]) 99 | _len_0 = _len_0 + 1 100 | end 101 | return _accum_0 102 | end)(), "&") 103 | else 104 | query = params 105 | end 106 | local to_sign = concat({ 107 | verb:upper(), 108 | host:lower(), 109 | path, 110 | query 111 | }, "\n") 112 | return encode_base64(hmac_sha1(self.secret, to_sign)) 113 | end, 114 | _action = function(self, name, opts, method) 115 | if method == nil then 116 | method = "GET" 117 | end 118 | opts = extend({ 119 | Action = name, 120 | AWSAccessKeyId = self.access_key, 121 | SignatureVersion = "2", 122 | SignatureMethod = "HmacSHA1", 123 | Timestamp = os.date("!%FT%TZ"), 124 | Version = "2010-08-28" 125 | }, opts) 126 | if debug then 127 | local moon = require("moon") 128 | io.stdout:write("Amazon API:\n") 129 | io.stdout:write(moon.dump(opts)) 130 | end 131 | local parsed_api_url = parse_url(self.endpoint.api_url) 132 | opts.Signature = self:sign_params(opts, method, parsed_api_url.host, parsed_api_url.path or "/") 133 | local out = { } 134 | local _, code, res_headers = http.request({ 135 | url = self.endpoint.api_url .. "?" .. encode_query_string(opts), 136 | method = method, 137 | sink = ltn12.sink.table(out) 138 | }) 139 | local lom = require("lxp.lom") 140 | out = table.concat(out) 141 | if debug then 142 | io.stdout:write(out) 143 | io.stdout:write("\n\n") 144 | end 145 | local res, err = lom.parse(out) 146 | if not (res) then 147 | return nil, err, code, out 148 | end 149 | if code == 200 then 150 | return res, code, out 151 | else 152 | return nil, extract_errors(res), code, out 153 | end 154 | end, 155 | verify_request = function(self, req) 156 | local uri = ngx.var.request_uri 157 | local path, params = uri:match("^([^?]*)%?(.*)$") 158 | if not (path) then 159 | path = uri 160 | params = "" 161 | end 162 | if ngx.var.request_method == "POST" then 163 | params = ngx.req.get_body_data() 164 | end 165 | return self:verify_signature(req:build_url(path), params) 166 | end, 167 | verify_signature = function(self, endpoint, param_str) 168 | local res, code, err_code = self:_action("VerifySignature", { 169 | UrlEndPoint = endpoint, 170 | HttpParameters = param_str 171 | }) 172 | if not (res) then 173 | return nil, code, err_code 174 | end 175 | local status = node_value(find_node(res, "VerifySignatureResult"), "VerificationStatus") 176 | return (status and status:lower()) == "success", code 177 | end, 178 | pay = function(self, opts) 179 | local amount, sender, recipient, fee 180 | amount, sender, recipient, fee = opts.amount, opts.sender, opts.recipient, opts.fee 181 | assert(valid_amount(amount)) 182 | local res, code, err_code, err_raw = self:_action("Pay", { 183 | ["TransactionAmount.CurrencyCode"] = "USD", 184 | ["TransactionAmount.Value"] = amount, 185 | ChargeFeeTo = "Recipient", 186 | MarketplaceVariableFee = fee or nil, 187 | CallerReference = self:gen_reference("dopay"), 188 | CallerDescription = "TEST", 189 | OverrideIPNURL = self.override_ipn, 190 | RecipientTokenId = recipient, 191 | SenderTokenId = sender 192 | }) 193 | if not (res) then 194 | return nil, code, err_code, err_raw 195 | end 196 | local transaction_id = node_value(find_node(res, "PayResult"), "TransactionId") 197 | return transaction_id, code, err_code, err_raw 198 | end, 199 | get_trasaction_status = function(self, transaction_id) 200 | return self:_action("GetTransactionStatus", { 201 | TransactionId = transaction_id 202 | }) 203 | end, 204 | cobranded_url = function(self, opts) 205 | opts = extend({ 206 | callerKey = self.access_key, 207 | signatureVersion = "2", 208 | signatureMethod = "HmacSHA1" 209 | }, opts) 210 | local parsed_cobranded = parse_url(self.endpoint.cobranded_url) 211 | opts.signature = self:sign_params(opts, "GET", parsed_cobranded.host, parsed_cobranded.path or "/") 212 | return self.endpoint.cobranded_url .. "?" .. encode_query_string(opts) 213 | end, 214 | cobranded_pay_url = function(self, amount, return_url, opts) 215 | assert(valid_amount(amount)) 216 | return self:cobranded_url(extend({ 217 | pipelineName = "SingleUse", 218 | transactionAmount = amount, 219 | returnURL = return_url, 220 | callerReference = self:gen_reference("pay") 221 | }, opts)) 222 | end, 223 | cobranded_register_url = function(self, return_url, opts) 224 | local max_fee = opts.max_fee 225 | opts.max_fee = nil 226 | return self:cobranded_url(extend({ 227 | pipelineName = "Recipient", 228 | callerReference = self:gen_reference("link"), 229 | recipientPaysFee = "True", 230 | paymentMethod = "CC,ACH,ABT", 231 | maxVariableFee = max_fee or nil, 232 | returnURL = return_url 233 | }, opts)) 234 | end, 235 | gen_reference = function(self, prefix) 236 | if prefix == nil then 237 | prefix = "ref" 238 | end 239 | return tostring(prefix) .. "_" .. tostring(os.time()) .. "_" .. tostring(math.random(1, 10000)) 240 | end 241 | } 242 | _base_0.__index = _base_0 243 | _class_0 = setmetatable({ 244 | __init = function(self, access_key, secret, opts) 245 | self.access_key, self.secret = access_key, secret 246 | self.endpoint = self.__class.sandbox_endpoint 247 | for k, v in pairs(opts) do 248 | self[k] = v 249 | end 250 | end, 251 | __base = _base_0, 252 | __name = "AmazonFPS" 253 | }, { 254 | __index = _base_0, 255 | __call = function(cls, ...) 256 | local _self_0 = setmetatable({}, _base_0) 257 | cls.__init(_self_0, ...) 258 | return _self_0 259 | end 260 | }) 261 | _base_0.__class = _class_0 262 | local self = _class_0 263 | self.sandbox_endpoint = { 264 | api_url = "https://fps.sandbox.amazonaws.com/", 265 | cobranded_url = "https://authorize.payments-sandbox.amazon.com/cobranded-ui/actions/start" 266 | } 267 | self.production_endpoint = { 268 | api_url = "https://fps.amazonaws.com/", 269 | cobranded_url = "https://authorize.payments.amazon.com/cobranded-ui/actions/start" 270 | } 271 | find_node = function(nodes, tag) 272 | if not (nodes) then 273 | return 274 | end 275 | for _index_0 = 1, #nodes do 276 | local node = nodes[_index_0] 277 | if node.tag == tag then 278 | return node 279 | end 280 | end 281 | end 282 | filter_nodes = function(node, tag) 283 | if not (node) then 284 | return 285 | end 286 | return (function() 287 | local _accum_0 = { } 288 | local _len_0 = 1 289 | for _index_0 = 1, #node do 290 | local _continue_0 = false 291 | repeat 292 | local child = node[_index_0] 293 | if not (child.tag == tag) then 294 | _continue_0 = true 295 | break 296 | end 297 | local _value_0 = child 298 | _accum_0[_len_0] = _value_0 299 | _len_0 = _len_0 + 1 300 | _continue_0 = true 301 | until true 302 | if not _continue_0 then 303 | break 304 | end 305 | end 306 | return _accum_0 307 | end)() 308 | end 309 | node_value = function(nodes, tag) 310 | do 311 | local found = find_node(nodes, tag) 312 | if found then 313 | return found[1] 314 | end 315 | end 316 | end 317 | extract_errors = function(nodes) 318 | local errors = filter_nodes(find_node(nodes, "Errors"), "Error") 319 | if not errors or #errors == 0 then 320 | return nil 321 | end 322 | return (function() 323 | local _accum_0 = { } 324 | local _len_0 = 1 325 | for _index_0 = 1, #errors do 326 | local e = errors[_index_0] 327 | _accum_0[_len_0] = { 328 | code = node_value(e, "Code"), 329 | message = node_value(e, "Message") 330 | } 331 | _len_0 = _len_0 + 1 332 | end 333 | return _accum_0 334 | end)() 335 | end 336 | AmazonFPS = _class_0 337 | end 338 | return { 339 | AmazonFPS = AmazonFPS, 340 | url_encode = url_encode 341 | } 342 | -------------------------------------------------------------------------------- /payments/amazon.moon: -------------------------------------------------------------------------------- 1 | 2 | debug = false 3 | 4 | http = require "lapis.nginx.http" 5 | ltn12 = require "ltn12" 6 | 7 | import encode_query_string, parse_query_string from require "lapis.util" 8 | import hmac_sha1, encode_base64, decode_base64 from require "lapis.util.encoding" 9 | 10 | import sort, concat from table 11 | 12 | parse_url = require"socket.url".parse 13 | 14 | extend = (a, ...) -> 15 | for t in *{...} 16 | if t 17 | a[k] = v for k,v in pairs t 18 | a 19 | 20 | format_price = (cents) -> 21 | dollars = math.floor cents / 100 22 | change = "%02d"\format cents % 100 23 | "#{dollars}.#{change}" 24 | 25 | valid_amount = (str) -> 26 | return true if str\match "%d+%.%d%d" 27 | nil, "invalid amount (#{str})" 28 | 29 | -- url encoding as defined by amazon 30 | url_encode = (str) -> 31 | (str\gsub "[^a-zA-Z0-9_.~%-]", (chr) -> 32 | byte = chr\byte! 33 | hex = "%02x"\format(byte)\upper! 34 | "%#{hex}") 35 | 36 | class AmazonFPS 37 | @sandbox_endpoint: { 38 | api_url: "https://fps.sandbox.amazonaws.com/" 39 | cobranded_url: "https://authorize.payments-sandbox.amazon.com/cobranded-ui/actions/start" 40 | } 41 | 42 | @production_endpoint: { 43 | api_url: "https://fps.amazonaws.com/" 44 | cobranded_url: "https://authorize.payments.amazon.com/cobranded-ui/actions/start" 45 | } 46 | 47 | override_ipn: nil 48 | 49 | find_node = (nodes, tag) -> 50 | return unless nodes 51 | for node in *nodes 52 | if node.tag == tag 53 | return node 54 | 55 | filter_nodes = (node, tag) -> 56 | return unless node 57 | return for child in *node 58 | continue unless child.tag == tag 59 | child 60 | 61 | node_value = (nodes, tag) -> 62 | if found = find_node nodes, tag 63 | found[1] 64 | 65 | extract_errors = (nodes) -> 66 | errors = filter_nodes find_node(nodes, "Errors"), "Error" 67 | return nil if not errors or #errors == 0 68 | return for e in *errors 69 | { 70 | code: node_value e, "Code" 71 | message: node_value e, "Message" 72 | } 73 | 74 | new: (@access_key, @secret, opts) => 75 | @endpoint = @@sandbox_endpoint 76 | for k,v in pairs opts 77 | @[k] = v 78 | 79 | format_price: (...) => format_price ... 80 | 81 | -- StringToSign = HTTPVerb + "\n" + 82 | -- ValueOfHostHeaderInLowercase + "\n" + 83 | -- HTTPRequestURI + "\n" + 84 | -- CanonicalizedQueryString 85 | sign_params: (params, verb="GET", host=error"missing host", path=error"missing path") => 86 | query = if type(params) == "table" 87 | tuples = [{k, v} for k,v in pairs params] 88 | sort tuples, (left, right) -> left[1] < right[1] 89 | concat [url_encode(t[1]) .. "=" .. url_encode(t[2]) for t in *tuples], "&" 90 | else 91 | params 92 | 93 | to_sign = concat { verb\upper!, host\lower!, path, query }, "\n" 94 | encode_base64 hmac_sha1 @secret, to_sign 95 | 96 | _action: (name, opts, method="GET") => 97 | opts = extend { 98 | Action: name 99 | AWSAccessKeyId: @access_key 100 | SignatureVersion: "2" 101 | SignatureMethod: "HmacSHA1" 102 | Timestamp: os.date "!%FT%TZ" 103 | Version: "2010-08-28" 104 | }, opts 105 | 106 | if debug 107 | moon = require "moon" 108 | io.stdout\write "Amazon API:\n" 109 | io.stdout\write moon.dump opts 110 | 111 | parsed_api_url = parse_url @endpoint.api_url 112 | opts.Signature = @sign_params opts, method, 113 | parsed_api_url.host, parsed_api_url.path or "/" 114 | 115 | out = {} 116 | _, code, res_headers = http.request { 117 | url: @endpoint.api_url .. "?" .. encode_query_string opts 118 | method: method 119 | sink: ltn12.sink.table out 120 | } 121 | 122 | lom = require "lxp.lom" 123 | 124 | out = table.concat out 125 | 126 | if debug 127 | io.stdout\write out 128 | io.stdout\write "\n\n" 129 | 130 | res, err = lom.parse out 131 | return nil, err, code, out unless res 132 | 133 | if code == 200 134 | res, code, out 135 | else 136 | nil, extract_errors(res), code, out 137 | 138 | -- verify the signature of the current request 139 | verify_request: (req) => 140 | uri = ngx.var.request_uri 141 | path, params = uri\match "^([^?]*)%?(.*)$" 142 | 143 | unless path 144 | path = uri 145 | params = "" 146 | 147 | if ngx.var.request_method == "POST" 148 | params = ngx.req.get_body_data! 149 | 150 | @verify_signature req\build_url(path), params 151 | 152 | verify_signature: (endpoint, param_str) => 153 | res, code, err_code = @_action "VerifySignature", { 154 | UrlEndPoint: endpoint 155 | HttpParameters: param_str 156 | } 157 | 158 | return nil, code, err_code unless res 159 | status = node_value(find_node(res, "VerifySignatureResult"), "VerificationStatus") 160 | (status and status\lower!) == "success", code 161 | 162 | pay: (opts) => -- amount, sender_token_id, recipient_token_id, opts={}) => 163 | {:amount, :sender, :recipient, :fee} = opts 164 | 165 | assert valid_amount amount 166 | res, code, err_code, err_raw = @_action "Pay", { 167 | "TransactionAmount.CurrencyCode": "USD" 168 | "TransactionAmount.Value": amount 169 | ChargeFeeTo: "Recipient" 170 | MarketplaceVariableFee: fee or nil 171 | CallerReference: @gen_reference "dopay" 172 | CallerDescription: "TEST" 173 | OverrideIPNURL: @override_ipn 174 | 175 | RecipientTokenId: recipient 176 | SenderTokenId: sender 177 | } 178 | 179 | return nil, code, err_code, err_raw unless res 180 | transaction_id = node_value find_node(res, "PayResult"), "TransactionId" 181 | transaction_id, code, err_code, err_raw 182 | 183 | get_trasaction_status: (transaction_id) => 184 | @_action "GetTransactionStatus", { 185 | TransactionId: transaction_id 186 | } 187 | 188 | cobranded_url: (opts) => 189 | opts = extend { 190 | callerKey: @access_key 191 | signatureVersion: "2" 192 | signatureMethod: "HmacSHA1" 193 | }, opts 194 | 195 | parsed_cobranded = parse_url @endpoint.cobranded_url 196 | opts.signature = @sign_params opts, "GET", 197 | parsed_cobranded.host, parsed_cobranded.path or "/" 198 | 199 | @endpoint.cobranded_url .. "?" .. encode_query_string opts 200 | 201 | -- https://authorize.payments.amazon.com/cobranded-ui/actions/start? 202 | -- callerKey=[The caller's AWS Access Key ID] 203 | -- &callerReference=DigitalDownload1183401134541 204 | -- &pipelineName=SingleUse 205 | -- &returnURL=http%3A%2F%2Fwww.digitaldownload.com%2FpaymentDetails.jsp%3FPaymentAmount%3 206 | -- D0.10%26Download%3DCandle%2BIn%2Bthe%2BWind%2B-%2BElton%2BJohn%26uniqueId%3D1183401134535 207 | -- &paymentReason=To download Candle In the Wind - Elton John 208 | -- &signature=[URL-encoded value you generate] 209 | -- &transactionAmount=0.10 210 | cobranded_pay_url: (amount, return_url, opts) => 211 | assert valid_amount amount 212 | @cobranded_url extend { 213 | pipelineName: "SingleUse" 214 | transactionAmount: amount 215 | returnURL: return_url 216 | callerReference: @gen_reference "pay" 217 | }, opts 218 | 219 | cobranded_register_url: (return_url, opts) => 220 | max_fee = opts.max_fee 221 | opts.max_fee = nil 222 | 223 | @cobranded_url extend { 224 | pipelineName: "Recipient" 225 | callerReference: @gen_reference "link" 226 | recipientPaysFee: "True" 227 | paymentMethod: "CC,ACH,ABT" 228 | maxVariableFee: max_fee or nil 229 | returnURL: return_url 230 | }, opts 231 | 232 | gen_reference: (prefix="ref") => 233 | "#{prefix}_#{os.time!}_#{math.random 1, 10000}" 234 | 235 | { :AmazonFPS, :url_encode } 236 | -------------------------------------------------------------------------------- /payments/base_client.lua: -------------------------------------------------------------------------------- 1 | local BaseClient 2 | do 3 | local _class_0 4 | local _base_0 = { 5 | http = function(self) 6 | if not (self._http) then 7 | self.http_provider = self.http_provider or (function() 8 | if ngx and ngx.socket then 9 | return "lapis.nginx.http" 10 | else 11 | return "ssl.https" 12 | end 13 | end)() 14 | if type(self.http_provider) == "function" then 15 | self._http = self:http_provider() 16 | else 17 | self._http = require(self.http_provider) 18 | end 19 | end 20 | return self._http 21 | end 22 | } 23 | _base_0.__index = _base_0 24 | _class_0 = setmetatable({ 25 | __init = function(self, opts) 26 | if opts then 27 | self.http_provider = opts.http_provider 28 | end 29 | end, 30 | __base = _base_0, 31 | __name = "BaseClient" 32 | }, { 33 | __index = _base_0, 34 | __call = function(cls, ...) 35 | local _self_0 = setmetatable({}, _base_0) 36 | cls.__init(_self_0, ...) 37 | return _self_0 38 | end 39 | }) 40 | _base_0.__class = _class_0 41 | BaseClient = _class_0 42 | return _class_0 43 | end 44 | -------------------------------------------------------------------------------- /payments/base_client.moon: -------------------------------------------------------------------------------- 1 | class BaseClient 2 | new: (opts) => 3 | if opts 4 | @http_provider = opts.http_provider 5 | 6 | http: => 7 | unless @_http 8 | -- for cqeuues "http.compat.socket" 9 | @http_provider or= if ngx and ngx.socket 10 | "lapis.nginx.http" 11 | else 12 | "ssl.https" 13 | 14 | @_http = if type(@http_provider) == "function" 15 | @http_provider! 16 | else 17 | require @http_provider 18 | 19 | @_http 20 | 21 | 22 | -------------------------------------------------------------------------------- /payments/paypal.lua: -------------------------------------------------------------------------------- 1 | return { 2 | PayPalAdaptive = require("payments.paypal.adaptive"), 3 | PayPalRest = require("payments.paypal.rest"), 4 | PayPalExpressCheckout = require("payments.paypal.express_checkout") 5 | } 6 | -------------------------------------------------------------------------------- /payments/paypal.moon: -------------------------------------------------------------------------------- 1 | { 2 | PayPalAdaptive: require "payments.paypal.adaptive" 3 | PayPalRest: require "payments.paypal.rest" 4 | PayPalExpressCheckout: require "payments.paypal.express_checkout" 5 | } 6 | -------------------------------------------------------------------------------- /payments/paypal/adaptive.lua: -------------------------------------------------------------------------------- 1 | local concat 2 | concat = table.concat 3 | local types 4 | types = require("tableshape").types 5 | local extend, strip_numeric, format_price 6 | do 7 | local _obj_0 = require("payments.paypal.helpers") 8 | extend, strip_numeric, format_price = _obj_0.extend, _obj_0.strip_numeric, _obj_0.format_price 9 | end 10 | local encode_query_string, parse_query_string 11 | do 12 | local _obj_0 = require("lapis.util") 13 | encode_query_string, parse_query_string = _obj_0.encode_query_string, _obj_0.parse_query_string 14 | end 15 | local ltn12 = require("ltn12") 16 | local PayPalAdaptive 17 | do 18 | local _class_0 19 | local _parent_0 = require("payments.base_client") 20 | local _base_0 = { 21 | _method = function(self, action, params) 22 | local headers = { 23 | ["X-PAYPAL-SECURITY-USERID"] = self.auth.USER, 24 | ["X-PAYPAL-SECURITY-PASSWORD"] = self.auth.PWD, 25 | ["X-PAYPAL-SECURITY-SIGNATURE"] = self.auth.SIGNATURE, 26 | ["X-PAYPAL-REQUEST-DATA-FORMAT"] = "NV", 27 | ["X-PAYPAL-RESPONSE-DATA-FORMAT"] = "NV", 28 | ["X-PAYPAL-APPLICATION-ID"] = self.application_id 29 | } 30 | params = extend({ 31 | ["clientDetails.applicationId"] = self.application_id 32 | }, params) 33 | local body = encode_query_string(params) 34 | local parse_url = require("socket.url").parse 35 | local host = assert(parse_url(self.api_url).host) 36 | headers["Host"] = host 37 | headers["Content-length"] = tostring(#body) 38 | local out = { } 39 | local _, code, res_headers = assert(self:http().request({ 40 | headers = headers, 41 | url = tostring(self.api_url) .. "/" .. tostring(action), 42 | source = ltn12.source.string(body), 43 | method = "POST", 44 | sink = ltn12.sink.table(out), 45 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 46 | })) 47 | local text = concat(out) 48 | local res = parse_query_string(text) or text 49 | if type(res) == "table" then 50 | strip_numeric(res) 51 | end 52 | return self:_extract_error(res, res_headers) 53 | end, 54 | _extract_error = function(self, res, msg) 55 | if res == nil then 56 | res = { } 57 | end 58 | if msg == nil then 59 | msg = "paypal failed" 60 | end 61 | if (res["responseEnvelope.ack"] or ""):lower() == "success" then 62 | return res 63 | else 64 | return nil, res["error(0).message"], res 65 | end 66 | end, 67 | pay = function(self, params) 68 | if params == nil then 69 | params = { } 70 | end 71 | assert(params.receivers and #params.receivers > 0, "there must be at least one receiver") 72 | local params_shape = types.shape({ 73 | cancelUrl = types.string, 74 | returnUrl = types.string, 75 | receivers = types.array_of(types.shape({ 76 | email = types.string, 77 | amount = types.string 78 | }, { 79 | open = true 80 | })) 81 | }, { 82 | open = true 83 | }) 84 | assert(params_shape(params)) 85 | local receivers = params.receivers 86 | params.receivers = nil 87 | params = extend({ 88 | actionType = "PAY", 89 | currencyCode = "USD", 90 | feesPayer = (function() 91 | if #receivers > 1 then 92 | return "PRIMARYRECEIVER" 93 | end 94 | end)(), 95 | ["requestEnvelope.errorLanguage"] = "en_US", 96 | cancelUrl = params.returnUrl, 97 | returnUrl = params.cancelUrl 98 | }, params) 99 | for i, r in ipairs(receivers) do 100 | i = i - 1 101 | for rk, rv in pairs(r) do 102 | params["receiverList.receiver(" .. tostring(i) .. ")." .. tostring(rk)] = tostring(rv) 103 | end 104 | end 105 | return self:_method("AdaptivePayments/Pay", params) 106 | end, 107 | convert_currency = function(self, amount, source, dest) 108 | if source == nil then 109 | source = "USD" 110 | end 111 | if dest == nil then 112 | dest = "AUD" 113 | end 114 | return self:_method("AdaptivePayments/ConvertCurrency", extend({ 115 | ["requestEnvelope.errorLanguage"] = "en_US", 116 | ["baseAmountList.currency(0).code"] = source, 117 | ["baseAmountList.currency(0).amount"] = tostring(amount), 118 | ["convertToCurrencyList.currencyCode"] = dest 119 | })) 120 | end, 121 | refund = function(self, pay_key, params) 122 | return self:_method("AdaptivePayments/Refund", extend({ 123 | payKey = pay_key, 124 | ["requestEnvelope.errorLanguage"] = "en_US" 125 | }, params)) 126 | end, 127 | payment_details = function(self, params) 128 | assert(params.payKey or params.transactionId or params.trackingId, "Missing one of payKey, transactionId or trackingId") 129 | return self:_method("AdaptivePayments/PaymentDetails", extend({ 130 | ["requestEnvelope.errorLanguage"] = "en_US" 131 | }, params)) 132 | end, 133 | get_shipping_addresses = function(self, pay_key, params) 134 | return self:_method("AdaptivePayments/GetShippingAddresses", extend({ 135 | key = pay_key, 136 | ["requestEnvelope.errorLanguage"] = "en_US" 137 | }, params)) 138 | end, 139 | set_payment_options = function(self, pay_key, params) 140 | return self:_method("AdaptivePayments/SetPaymentOptions", extend({ 141 | payKey = pay_key, 142 | ["requestEnvelope.errorLanguage"] = "en_US" 143 | }, params)) 144 | end, 145 | checkout_url = function(self, pay_key) 146 | return tostring(self.base_url) .. "/webscr?cmd=_ap-payment&paykey=" .. tostring(pay_key) 147 | end, 148 | format_price = function(self, ...) 149 | return format_price(...) 150 | end 151 | } 152 | _base_0.__index = _base_0 153 | setmetatable(_base_0, _parent_0.__base) 154 | _class_0 = setmetatable({ 155 | __init = function(self, opts) 156 | self.opts = opts 157 | self.auth = assert(self.opts.auth, "missing auth") 158 | assert(self.__class.auth_shape(self.auth)) 159 | self.application_id = assert(self.opts.application_id, "missing application id") 160 | local urls = self.opts.sandbox and self.__class.urls.sandbox or self.__class.urls.live 161 | self.api_url = self.opts.api_url or urls.api 162 | self.base_url = self.opts.base_url or urls.base 163 | return _class_0.__parent.__init(self, self.opts) 164 | end, 165 | __base = _base_0, 166 | __name = "PayPalAdaptive", 167 | __parent = _parent_0 168 | }, { 169 | __index = function(cls, name) 170 | local val = rawget(_base_0, name) 171 | if val == nil then 172 | local parent = rawget(cls, "__parent") 173 | if parent then 174 | return parent[name] 175 | end 176 | else 177 | return val 178 | end 179 | end, 180 | __call = function(cls, ...) 181 | local _self_0 = setmetatable({}, _base_0) 182 | cls.__init(_self_0, ...) 183 | return _self_0 184 | end 185 | }) 186 | _base_0.__class = _class_0 187 | local self = _class_0 188 | self.urls = { 189 | live = { 190 | base = "https://www.paypal.com", 191 | api = "https://svcs.paypal.com" 192 | }, 193 | sandbox = { 194 | base = "https://www.sandbox.paypal.com", 195 | api = "https://svcs.sandbox.paypal.com" 196 | } 197 | } 198 | self.auth_shape = types.shape({ 199 | USER = types.string, 200 | PWD = types.string, 201 | SIGNATURE = types.string 202 | }) 203 | if _parent_0.__inherited then 204 | _parent_0.__inherited(_parent_0, _class_0) 205 | end 206 | PayPalAdaptive = _class_0 207 | return _class_0 208 | end 209 | -------------------------------------------------------------------------------- /payments/paypal/adaptive.moon: -------------------------------------------------------------------------------- 1 | import concat from table 2 | import types from require "tableshape" 3 | import extend, strip_numeric, format_price from require "payments.paypal.helpers" 4 | 5 | import encode_query_string, parse_query_string from require "lapis.util" 6 | 7 | ltn12 = require "ltn12" 8 | 9 | -- Paypal Adaptive Payments (Classic API): 10 | -- https://developer.paypal.com/docs/classic/api/#ap 11 | class PayPalAdaptive extends require "payments.base_client" 12 | @urls: { 13 | live: { 14 | base: "https://www.paypal.com" 15 | api: "https://svcs.paypal.com" 16 | } 17 | 18 | sandbox: { 19 | base: "https://www.sandbox.paypal.com" 20 | api: "https://svcs.sandbox.paypal.com" 21 | } 22 | } 23 | 24 | @auth_shape: types.shape { 25 | USER: types.string 26 | PWD: types.string 27 | SIGNATURE: types.string 28 | } 29 | 30 | new: (@opts) => 31 | @auth = assert @opts.auth, "missing auth" 32 | assert @@.auth_shape @auth 33 | 34 | @application_id = assert @opts.application_id, "missing application id" 35 | 36 | urls = @opts.sandbox and @@urls.sandbox or @@urls.live 37 | @api_url = @opts.api_url or urls.api 38 | @base_url = @opts.base_url or urls.base 39 | super @opts 40 | 41 | _method: (action, params) => 42 | headers = { 43 | "X-PAYPAL-SECURITY-USERID": @auth.USER 44 | "X-PAYPAL-SECURITY-PASSWORD": @auth.PWD 45 | "X-PAYPAL-SECURITY-SIGNATURE": @auth.SIGNATURE 46 | "X-PAYPAL-REQUEST-DATA-FORMAT": "NV" -- Name-Value pair (rather than SOAP) 47 | "X-PAYPAL-RESPONSE-DATA-FORMAT": "NV" 48 | "X-PAYPAL-APPLICATION-ID": @application_id 49 | } 50 | 51 | params = extend { 52 | "clientDetails.applicationId": @application_id 53 | }, params 54 | 55 | body = encode_query_string params 56 | 57 | parse_url = require("socket.url").parse 58 | host = assert parse_url(@api_url).host 59 | headers["Host"] = host 60 | headers["Content-length"] = tostring #body 61 | 62 | out = {} 63 | _, code, res_headers = assert @http!.request { 64 | :headers 65 | url: "#{@api_url}/#{action}" 66 | source: ltn12.source.string body 67 | method: "POST" 68 | sink: ltn12.sink.table out 69 | 70 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 71 | } 72 | 73 | text = concat out 74 | res = parse_query_string(text) or text 75 | strip_numeric(res) if type(res) == "table" 76 | 77 | @_extract_error res, res_headers 78 | 79 | _extract_error: (res={}, msg="paypal failed") => 80 | if (res["responseEnvelope.ack"] or "")\lower! == "success" 81 | res 82 | else 83 | nil, res["error(0).message"], res 84 | 85 | pay: (params={}) => 86 | assert params.receivers and #params.receivers > 0, 87 | "there must be at least one receiver" 88 | 89 | params_shape = types.shape { 90 | cancelUrl: types.string 91 | returnUrl: types.string 92 | 93 | receivers: types.array_of types.shape { 94 | email: types.string 95 | amount: types.string 96 | }, open: true 97 | 98 | }, open: true 99 | 100 | assert params_shape params 101 | 102 | receivers = params.receivers 103 | params.receivers = nil 104 | 105 | params = extend { 106 | actionType: "PAY" 107 | currencyCode: "USD" 108 | feesPayer: if #receivers > 1 then "PRIMARYRECEIVER" 109 | "requestEnvelope.errorLanguage": "en_US" 110 | 111 | cancelUrl: params.returnUrl 112 | returnUrl: params.cancelUrl 113 | }, params 114 | 115 | for i, r in ipairs receivers 116 | i -= 1 117 | for rk, rv in pairs r 118 | params["receiverList.receiver(#{i}).#{rk}"] = tostring rv 119 | 120 | @_method "AdaptivePayments/Pay", params 121 | 122 | convert_currency: (amount, source="USD", dest="AUD") => 123 | @_method "AdaptivePayments/ConvertCurrency", extend { 124 | "requestEnvelope.errorLanguage": "en_US" 125 | "baseAmountList.currency(0).code": source 126 | "baseAmountList.currency(0).amount": tostring amount 127 | "convertToCurrencyList.currencyCode": dest 128 | } 129 | 130 | refund: (pay_key, params) => 131 | -- https://developer.paypal.com/docs/classic/api/adaptive-payments/Refund_API_Operation/ 132 | @_method "AdaptivePayments/Refund", extend { 133 | payKey: pay_key 134 | "requestEnvelope.errorLanguage": "en_US" 135 | }, params 136 | 137 | payment_details: (params) => 138 | assert params.payKey or params.transactionId or params.trackingId, 139 | "Missing one of payKey, transactionId or trackingId" 140 | 141 | @_method "AdaptivePayments/PaymentDetails", extend { 142 | "requestEnvelope.errorLanguage": "en_US" 143 | }, params 144 | 145 | get_shipping_addresses: (pay_key, params) => 146 | @_method "AdaptivePayments/GetShippingAddresses", extend { 147 | key: pay_key 148 | "requestEnvelope.errorLanguage": "en_US" 149 | }, params 150 | 151 | set_payment_options: (pay_key, params) => 152 | @_method "AdaptivePayments/SetPaymentOptions", extend { 153 | payKey: pay_key 154 | "requestEnvelope.errorLanguage": "en_US" 155 | }, params 156 | 157 | checkout_url: (pay_key) => 158 | -- "https://www.paypal.com/webapps/adaptivepayment/flow/pay?paykey=#{pay_key}" 159 | "#{@base_url}/webscr?cmd=_ap-payment&paykey=#{pay_key}" 160 | 161 | format_price: (...) => format_price ... 162 | 163 | -------------------------------------------------------------------------------- /payments/paypal/express_checkout.lua: -------------------------------------------------------------------------------- 1 | local concat 2 | concat = table.concat 3 | local types 4 | types = require("tableshape").types 5 | local extend, format_price, upper_keys, strip_numeric, valid_amount 6 | do 7 | local _obj_0 = require("payments.paypal.helpers") 8 | extend, format_price, upper_keys, strip_numeric, valid_amount = _obj_0.extend, _obj_0.format_price, _obj_0.upper_keys, _obj_0.strip_numeric, _obj_0.valid_amount 9 | end 10 | local encode_query_string, parse_query_string 11 | do 12 | local _obj_0 = require("lapis.util") 13 | encode_query_string, parse_query_string = _obj_0.encode_query_string, _obj_0.parse_query_string 14 | end 15 | local ltn12 = require("ltn12") 16 | local PayPalExpressCheckout 17 | do 18 | local _class_0 19 | local _parent_0 = require("payments.base_client") 20 | local _base_0 = { 21 | _method = function(self, name, params) 22 | params.METHOD = name 23 | local out = { } 24 | for k, v in pairs(self.auth) do 25 | if not (params[k]) then 26 | params[k] = v 27 | end 28 | end 29 | local body = encode_query_string(params) 30 | local parse_url = require("socket.url").parse 31 | local success, code, res_headers = self:http().request({ 32 | url = self.api_url, 33 | headers = { 34 | ["Host"] = assert(parse_url(self.api_url).host, "failed to get host"), 35 | ["Content-type"] = "application/x-www-form-urlencoded", 36 | ["Content-length"] = tostring(#body) 37 | }, 38 | source = ltn12.source.string(body), 39 | method = "POST", 40 | sink = ltn12.sink.table(out), 41 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 42 | }) 43 | assert(success, code) 44 | local text = concat(out) 45 | local res = parse_query_string(text) or text 46 | if type(res) == "table" then 47 | strip_numeric(res) 48 | end 49 | return self:_extract_error(res, res_headers) 50 | end, 51 | _extract_error = function(self, res, headers) 52 | if res.ACK ~= "Success" and res.ACK ~= "SuccessWithWarning" then 53 | return nil, res.L_LONGMESSAGE0, res, headers 54 | end 55 | return res, headers 56 | end, 57 | refund = function(self, transaction_id, opts) 58 | return self:_method("RefundTransaction", extend({ 59 | TRANSACTIONID = transaction_id 60 | }, upper_keys(opts))) 61 | end, 62 | _format_transaction_results = function(self, res) 63 | local warning_fields = { 64 | longmessage = true, 65 | shortmessage = true, 66 | severitycode = true, 67 | errorcode = true 68 | } 69 | local other_messages = { } 70 | local out = { } 71 | for k, val in pairs(res) do 72 | local field, id = k:match("L_(.-)(%d+)$") 73 | if field then 74 | field = field:lower() 75 | if warning_fields[field] then 76 | other_messages[k] = val 77 | else 78 | id = tonumber(id) + 1 79 | local _update_0 = id 80 | out[_update_0] = out[_update_0] or { } 81 | out[id][field] = val 82 | end 83 | else 84 | other_messages[k] = val 85 | end 86 | end 87 | return out, other_messages 88 | end, 89 | transaction_search = function(self, opts) 90 | local res, rest = self:_method("TransactionSearch", upper_keys(opts or { })) 91 | return self:_format_transaction_results(res), rest 92 | end, 93 | get_transaction_details = function(self, opts) 94 | return self:_method("GetTransactionDetails", upper_keys(opts or { })) 95 | end, 96 | set_express_checkout = function(self, opts) 97 | opts = upper_keys(opts) 98 | return self:_method("SetExpressCheckout", opts) 99 | end, 100 | get_express_checkout_details = function(self, token) 101 | return self:_method("GetExpressCheckoutDetails", { 102 | TOKEN = token 103 | }) 104 | end, 105 | do_express_checkout = function(self, token, payerid, amount, opts) 106 | assert(valid_amount(amount)) 107 | return self:_method("DoExpressCheckoutPayment", extend({ 108 | TOKEN = token, 109 | PAYERID = payerid, 110 | PAYMENTREQUEST_0_AMT = amount 111 | }, upper_keys(opts))) 112 | end, 113 | checkout_url = function(self, token) 114 | return tostring(self.checkout_url_prefix) .. "?cmd=_express-checkout&token=" .. tostring(token) .. "&useraction=commit" 115 | end, 116 | format_price = function(self, ...) 117 | return format_price(...) 118 | end, 119 | verify_ipn = function(self, original_body) 120 | local body = "cmd=_notify-validate&" .. original_body 121 | local out = { } 122 | local parse_url = require("socket.url").parse 123 | local success, code, headers = self:http().request({ 124 | url = self.checkout_url_prefix, 125 | headers = { 126 | ["Host"] = assert(parse_url(self.checkout_url_prefix).host, "failed to get host"), 127 | ["Content-type"] = "application/x-www-form-urlencoded", 128 | ["Content-length"] = tostring(#body) 129 | }, 130 | source = ltn12.source.string(body), 131 | method = "POST", 132 | sink = ltn12.sink.table(out), 133 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 134 | }) 135 | out = table.concat(out) 136 | return "VERIFIED" == out, code, out, headers 137 | end 138 | } 139 | _base_0.__index = _base_0 140 | setmetatable(_base_0, _parent_0.__base) 141 | _class_0 = setmetatable({ 142 | __init = function(self, opts) 143 | self.opts = opts 144 | self.auth = assert(self.opts.auth, "missing auth") 145 | self.auth.VERSION = self.auth.VERSION or "98" 146 | assert(self.__class.auth_shape(self.auth)) 147 | local urls = self.opts.sandbox and self.__class.urls.sandbox or self.__class.urls.live 148 | self.api_url = self.opts.api_url or urls.signature 149 | self.checkout_url_prefix = self.opts.checkout_url or urls.checkout 150 | return _class_0.__parent.__init(self, self.opts) 151 | end, 152 | __base = _base_0, 153 | __name = "PayPalExpressCheckout", 154 | __parent = _parent_0 155 | }, { 156 | __index = function(cls, name) 157 | local val = rawget(_base_0, name) 158 | if val == nil then 159 | local parent = rawget(cls, "__parent") 160 | if parent then 161 | return parent[name] 162 | end 163 | else 164 | return val 165 | end 166 | end, 167 | __call = function(cls, ...) 168 | local _self_0 = setmetatable({}, _base_0) 169 | cls.__init(_self_0, ...) 170 | return _self_0 171 | end 172 | }) 173 | _base_0.__class = _class_0 174 | local self = _class_0 175 | self.urls = { 176 | live = { 177 | checkout = "https://www.paypal.com/cgi-bin/webscr", 178 | certificate = "https://api.paypal.com/nvp", 179 | signature = "https://api-3t.paypal.com/nvp" 180 | }, 181 | sandbox = { 182 | checkout = "https://www.sandbox.paypal.com/cgi-bin/webscr", 183 | certificate = "https://api.sandbox.paypal.com/nvp", 184 | signature = "https://api-3t.sandbox.paypal.com/nvp" 185 | } 186 | } 187 | self.auth_shape = types.shape({ 188 | USER = types.string, 189 | PWD = types.string, 190 | SIGNATURE = types.string, 191 | VERSION = types.string:is_optional() 192 | }) 193 | if _parent_0.__inherited then 194 | _parent_0.__inherited(_parent_0, _class_0) 195 | end 196 | PayPalExpressCheckout = _class_0 197 | return _class_0 198 | end 199 | -------------------------------------------------------------------------------- /payments/paypal/express_checkout.moon: -------------------------------------------------------------------------------- 1 | 2 | import concat from table 3 | import types from require "tableshape" 4 | import extend, format_price, upper_keys, strip_numeric, valid_amount from require "payments.paypal.helpers" 5 | 6 | import encode_query_string, parse_query_string from require "lapis.util" 7 | 8 | ltn12 = require "ltn12" 9 | 10 | -- Paypal Express Checkout (Basic Classic API): 11 | -- https://developer.paypal.com/docs/classic/api/#ec 12 | class PayPalExpressCheckout extends require "payments.base_client" 13 | @urls: { 14 | live: { 15 | checkout: "https://www.paypal.com/cgi-bin/webscr" 16 | certificate: "https://api.paypal.com/nvp" 17 | signature: "https://api-3t.paypal.com/nvp" 18 | } 19 | 20 | sandbox: { 21 | checkout: "https://www.sandbox.paypal.com/cgi-bin/webscr" 22 | certificate: "https://api.sandbox.paypal.com/nvp" 23 | signature: "https://api-3t.sandbox.paypal.com/nvp" 24 | } 25 | } 26 | 27 | @auth_shape: types.shape { 28 | USER: types.string 29 | PWD: types.string 30 | SIGNATURE: types.string 31 | VERSION: types.string\is_optional! 32 | } 33 | 34 | new: (@opts) => 35 | @auth = assert @opts.auth, "missing auth" 36 | @auth.VERSION or= "98" 37 | 38 | assert @@.auth_shape @auth 39 | 40 | urls = @opts.sandbox and @@urls.sandbox or @@urls.live 41 | @api_url = @opts.api_url or urls.signature 42 | @checkout_url_prefix = @opts.checkout_url or urls.checkout 43 | super @opts 44 | 45 | _method: (name, params) => 46 | params.METHOD = name 47 | out = {} 48 | 49 | for k,v in pairs @auth 50 | params[k] = v unless params[k] 51 | 52 | body = encode_query_string params 53 | 54 | parse_url = require("socket.url").parse 55 | 56 | success, code, res_headers = @http!.request { 57 | url: @api_url 58 | 59 | headers: { 60 | "Host": assert parse_url(@api_url).host, "failed to get host" 61 | "Content-type": "application/x-www-form-urlencoded" 62 | "Content-length": tostring #body 63 | } 64 | 65 | source: ltn12.source.string body 66 | method: "POST" 67 | sink: ltn12.sink.table out 68 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 69 | } 70 | 71 | assert success, code 72 | 73 | text = concat out 74 | res = parse_query_string(text) or text 75 | strip_numeric(res) if type(res) == "table" 76 | @_extract_error res, res_headers 77 | 78 | _extract_error: (res, headers) => 79 | if res.ACK != "Success" and res.ACK != "SuccessWithWarning" 80 | return nil, res.L_LONGMESSAGE0, res, headers 81 | 82 | res, headers 83 | 84 | refund: (transaction_id, opts) => 85 | @_method "RefundTransaction", extend { 86 | TRANSACTIONID: transaction_id 87 | }, upper_keys opts 88 | 89 | _format_transaction_results: (res) => 90 | warning_fields = { 91 | longmessage: true 92 | shortmessage: true 93 | severitycode: true 94 | errorcode: true 95 | } 96 | 97 | other_messages = {} 98 | out = {} 99 | for k, val in pairs res 100 | field, id = k\match "L_(.-)(%d+)$" 101 | if field 102 | field = field\lower! 103 | if warning_fields[field] 104 | other_messages[k] = val 105 | else 106 | id = tonumber(id) + 1 -- make 1 indexed 107 | out[id] or= {} 108 | out[id][field] = val 109 | else 110 | other_messages[k] = val 111 | 112 | out, other_messages 113 | 114 | transaction_search: (opts) => 115 | res, rest = @_method "TransactionSearch", upper_keys opts or {} 116 | @_format_transaction_results(res), rest 117 | 118 | get_transaction_details: (opts) => 119 | @_method "GetTransactionDetails", upper_keys opts or {} 120 | 121 | -- amount: 0.00 122 | set_express_checkout: (opts) => 123 | opts = upper_keys opts 124 | @_method "SetExpressCheckout", opts 125 | 126 | get_express_checkout_details: (token) => 127 | @_method "GetExpressCheckoutDetails", TOKEN: token 128 | 129 | do_express_checkout: (token, payerid, amount, opts) => 130 | assert valid_amount amount 131 | 132 | @_method "DoExpressCheckoutPayment", extend { 133 | TOKEN: token 134 | PAYERID: payerid 135 | PAYMENTREQUEST_0_AMT: amount 136 | }, upper_keys opts 137 | 138 | checkout_url: (token) => 139 | "#{@checkout_url_prefix}?cmd=_express-checkout&token=#{token}&useraction=commit" 140 | 141 | format_price: (...) => format_price ... 142 | 143 | verify_ipn: (original_body) => 144 | body = "cmd=_notify-validate&" .. original_body 145 | 146 | out = {} 147 | 148 | parse_url = require("socket.url").parse 149 | 150 | success, code, headers = @http!.request { 151 | url: @checkout_url_prefix 152 | 153 | headers: { 154 | "Host": assert parse_url(@checkout_url_prefix).host, "failed to get host" 155 | "Content-type": "application/x-www-form-urlencoded" 156 | "Content-length": tostring #body 157 | } 158 | 159 | source: ltn12.source.string body 160 | method: "POST" 161 | sink: ltn12.sink.table out 162 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 163 | } 164 | 165 | out = table.concat out 166 | "VERIFIED" == out, code, out, headers 167 | 168 | 169 | -------------------------------------------------------------------------------- /payments/paypal/helpers.lua: -------------------------------------------------------------------------------- 1 | local extend 2 | extend = function(a, ...) 3 | local _list_0 = { 4 | ... 5 | } 6 | for _index_0 = 1, #_list_0 do 7 | local t = _list_0[_index_0] 8 | if t then 9 | for k, v in pairs(t) do 10 | a[k] = v 11 | end 12 | end 13 | end 14 | return a 15 | end 16 | local upper_keys 17 | upper_keys = function(t) 18 | if t then 19 | local _tbl_0 = { } 20 | for k, v in pairs(t) do 21 | _tbl_0[type(k) == "string" and k:upper() or k] = v 22 | end 23 | return _tbl_0 24 | end 25 | end 26 | local strip_numeric 27 | strip_numeric = function(t) 28 | for k, v in ipairs(t) do 29 | t[k] = nil 30 | end 31 | return t 32 | end 33 | local valid_amount 34 | valid_amount = function(str) 35 | if str:match("%d+%.%d%d") then 36 | return true 37 | end 38 | return nil, "invalid amount (" .. tostring(str) .. ")" 39 | end 40 | local format_price 41 | format_price = function(cents, currency) 42 | if currency == nil then 43 | currency = "USD" 44 | end 45 | if currency == "JPY" then 46 | return tostring(math.floor(cents)) 47 | else 48 | local dollars = math.floor(cents / 100) 49 | local change = ("%02d"):format(cents % 100) 50 | return tostring(dollars) .. "." .. tostring(change) 51 | end 52 | end 53 | return { 54 | extend = extend, 55 | upper_keys = upper_keys, 56 | strip_numeric = strip_numeric, 57 | valid_amount = valid_amount, 58 | format_price = format_price 59 | } 60 | -------------------------------------------------------------------------------- /payments/paypal/helpers.moon: -------------------------------------------------------------------------------- 1 | 2 | extend = (a, ...) -> 3 | for t in *{...} 4 | if t 5 | a[k] = v for k,v in pairs t 6 | a 7 | 8 | upper_keys = (t) -> 9 | if t 10 | { type(k) == "string" and k\upper! or k, v for k,v in pairs t } 11 | 12 | strip_numeric = (t) -> 13 | for k,v in ipairs t 14 | t[k] = nil 15 | t 16 | 17 | valid_amount = (str) -> 18 | return true if str\match "%d+%.%d%d" 19 | nil, "invalid amount (#{str})" 20 | 21 | format_price = (cents, currency="USD") -> 22 | if currency == "JPY" 23 | tostring math.floor cents 24 | else 25 | dollars = math.floor cents / 100 26 | change = "%02d"\format cents % 100 27 | "#{dollars}.#{change}" 28 | 29 | { :extend, :upper_keys, :strip_numeric, :valid_amount, :format_price } 30 | -------------------------------------------------------------------------------- /payments/paypal/rest.lua: -------------------------------------------------------------------------------- 1 | local json = require("cjson") 2 | local ltn12 = require("ltn12") 3 | local format_price, extend 4 | do 5 | local _obj_0 = require("payments.paypal.helpers") 6 | format_price, extend = _obj_0.format_price, _obj_0.extend 7 | end 8 | local encode_query_string 9 | encode_query_string = require("lapis.util").encode_query_string 10 | local concat 11 | concat = table.concat 12 | local PayPalRest 13 | do 14 | local _class_0 15 | local _parent_0 = require("payments.base_client") 16 | local _base_0 = { 17 | api_version = "v1", 18 | url_with_version = function(self, v) 19 | if v == nil then 20 | v = self.api_version 21 | end 22 | return tostring(self.url) .. tostring(v) .. "/" 23 | end, 24 | format_price = function(self, ...) 25 | return format_price(...) 26 | end, 27 | log_in_url = function(self, opts) 28 | if opts == nil then 29 | opts = { } 30 | end 31 | local url 32 | if self.sandbox then 33 | url = self.__class.urls.login_sandbox 34 | else 35 | url = self.__class.urls.login_default 36 | end 37 | local params = encode_query_string({ 38 | client_id = assert(self.client_id, "missing client id"), 39 | response_type = opts.response_type or "code", 40 | scope = opts.scope or "openid", 41 | redirect_uri = assert(opts.redirect_uri, "missing redirect uri"), 42 | nonce = opts.nonce, 43 | state = opts.state 44 | }) 45 | return tostring(url) .. "?" .. tostring(params) 46 | end, 47 | identity_token = function(self, opts) 48 | if opts == nil then 49 | opts = { } 50 | end 51 | local parse_url = require("socket.url").parse 52 | local url = tostring(self:url_with_version("v1")) .. "identity/openidconnect/tokenservice" 53 | local host = assert(parse_url(url).host) 54 | local body 55 | if opts.refresh_token then 56 | body = encode_query_string({ 57 | grant_type = "refresh_token", 58 | refresh_token = opts.refresh_token 59 | }) 60 | elseif opts.code then 61 | body = encode_query_string({ 62 | grant_type = "authorization_code", 63 | code = opts.code 64 | }) 65 | else 66 | body = error("unknown method for identity token (expecting code or refresh_token)") 67 | end 68 | local encode_base64 69 | encode_base64 = require("lapis.util.encoding").encode_base64 70 | local headers = { 71 | ["Host"] = host, 72 | ["Content-length"] = tostring(#body), 73 | ["Authorization"] = "Basic " .. tostring(encode_base64(tostring(self.client_id) .. ":" .. tostring(self.secret))), 74 | ["Content-Type"] = "application/x-www-form-urlencoded", 75 | ["Accept"] = "application/json", 76 | ["Accept-Language"] = "en_US" 77 | } 78 | local out = { } 79 | local res, status = assert(self:http().request({ 80 | method = "POST", 81 | url = url, 82 | headers = headers, 83 | sink = ltn12.sink.table(out), 84 | source = body and ltn12.source.string(body) or nil, 85 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 86 | })) 87 | out = table.concat(out, "") 88 | if out:match("^{") then 89 | out = json.decode(out) 90 | end 91 | if not (status == 200) then 92 | return nil, out 93 | end 94 | return out 95 | end, 96 | identity_userinfo = function(self, opts) 97 | if opts == nil then 98 | opts = { } 99 | end 100 | assert(opts.access_token, "missing access token") 101 | local res, status = self:_request({ 102 | method = "GET", 103 | path = "oauth2/token/userinfo", 104 | url_params = { 105 | schema = "openid" 106 | }, 107 | access_token = opts.access_token 108 | }) 109 | if not (status == 200) then 110 | return nil, res 111 | end 112 | return res 113 | end, 114 | need_refresh = function(self) 115 | if not (self.last_token) then 116 | return true 117 | end 118 | return os.time() > self.last_token_time + self.last_token.expires_in - 100 119 | end, 120 | refresh_token = function(self) 121 | if not (self:need_refresh()) then 122 | return 123 | end 124 | local encode_base64 125 | encode_base64 = require("lapis.util.encoding").encode_base64 126 | local out = { } 127 | local body = encode_query_string({ 128 | grant_type = "client_credentials" 129 | }) 130 | local parse_url = require("socket.url").parse 131 | local url = tostring(self:url_with_version("v1")) .. "oauth2/token" 132 | local host = assert(parse_url(url).host) 133 | local res, status = assert(self:http().request({ 134 | url = url, 135 | method = "POST", 136 | sink = ltn12.sink.table(out), 137 | source = ltn12.source.string(body), 138 | headers = { 139 | ["Host"] = host, 140 | ["Content-length"] = tostring(#body), 141 | ["Authorization"] = "Basic " .. tostring(encode_base64(tostring(self.client_id) .. ":" .. tostring(self.secret))), 142 | ["Content-Type"] = "application/x-www-form-urlencoded", 143 | ["Accept"] = "application/json", 144 | ["Accept-Language"] = "en_US" 145 | }, 146 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 147 | })) 148 | self.last_token_time = os.time() 149 | self.last_token = json.decode(concat(out)) 150 | self.access_token = self.last_token.access_token 151 | assert(self.access_token, "failed to get token from refresh (" .. tostring(status) .. ")") 152 | return true 153 | end, 154 | _request = function(self, opts) 155 | if opts == nil then 156 | opts = { } 157 | end 158 | assert(opts.method, "missing method") 159 | assert(opts.path, "missing path") 160 | local method, path, params, url_params 161 | method, path, params, url_params = opts.method, opts.path, opts.params, opts.url_params 162 | local authorization 163 | if opts.access_token then 164 | authorization = "Bearer " .. tostring(opts.access_token) 165 | else 166 | self:refresh_token() 167 | authorization = "Bearer " .. tostring(self.access_token) 168 | end 169 | local out = { } 170 | local body 171 | if params then 172 | body = json.encode(params) 173 | end 174 | local url = tostring(self:url_with_version(opts.api_version)) .. tostring(path) 175 | if url_params then 176 | url = url .. ("?" .. encode_query_string(url_params)) 177 | end 178 | local parse_url = require("socket.url").parse 179 | local host = assert(parse_url(self:url_with_version()).host) 180 | local headers = extend({ 181 | ["Host"] = host, 182 | ["Content-length"] = body and tostring(#body) or nil, 183 | ["Authorization"] = authorization, 184 | ["Content-Type"] = body and "application/json", 185 | ["Accept"] = "application/json", 186 | ["Accept-Language"] = "en_US" 187 | }, opts.headers) 188 | local res, status = assert(self:http().request({ 189 | url = url, 190 | method = method, 191 | headers = headers, 192 | sink = ltn12.sink.table(out), 193 | source = body and ltn12.source.string(body) or nil, 194 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 195 | })) 196 | out = concat(out) 197 | return json.decode(out), status 198 | end, 199 | get_payments = function(self, opts) 200 | return self:_request({ 201 | method = "GET", 202 | path = "payments/payment", 203 | params = opts 204 | }) 205 | end, 206 | payout = function(self, opts) 207 | local email = assert(opts.email, "missing email") 208 | local amount = assert(opts.amount, "missing amount") 209 | local currency = assert(opts.currency, "missing currency") 210 | assert(type(amount) == "string", "amount should be formatted as string (0.00)") 211 | local note = opts.note or "Payout" 212 | local email_subject = opts.email_subject or "You got a payout" 213 | return self:_request({ 214 | method = "POST", 215 | path = "payments/payouts", 216 | url_params = { 217 | sync_mode = "true" 218 | }, 219 | params = { 220 | sender_batch_header = { 221 | email_subject = email_subject 222 | }, 223 | items = { 224 | { 225 | recipient_type = "EMAIL", 226 | amount = { 227 | value = amount, 228 | currency = currency 229 | }, 230 | receiver = email, 231 | note = note 232 | } 233 | } 234 | } 235 | }) 236 | end, 237 | get_payout = function(self, batch_id) 238 | return self:_request({ 239 | method = "GET", 240 | path = "payments/payouts/" .. tostring(batch_id) 241 | }) 242 | end, 243 | get_balance = function(self, params) 244 | return self:_request({ 245 | method = "GET", 246 | path = "reporting/balances", 247 | url_params = params 248 | }) 249 | end, 250 | get_transaction_report = function(self, params) 251 | return self:_request({ 252 | method = "GET", 253 | path = "reporting/transactions", 254 | url_params = params 255 | }) 256 | end, 257 | get_sale_transaction = function(self, transaction_id, opts) 258 | return self:_request({ 259 | method = "GET", 260 | path = "payments/sale/" .. tostring(transaction_id), 261 | api_version = opts and opts.api_version 262 | }) 263 | end, 264 | sale_transaction = function(self, ...) 265 | return self:get_sale_transaction(...) 266 | end, 267 | create_payment = function(self, opts) 268 | return self:_request({ 269 | method = "POST", 270 | path = "payments/payment", 271 | params = opts 272 | }) 273 | end, 274 | execute_payment = function(self, payment_id, opts) 275 | return self:_request({ 276 | method = "POST", 277 | path = "payments/payment/" .. tostring(payment_id) .. "/execute", 278 | params = opts 279 | }) 280 | end, 281 | refund_sale = function(self, sale_id, opts) 282 | if opts == nil then 283 | opts = { } 284 | end 285 | return self:_request({ 286 | method = "POST", 287 | path = "payments/sale/" .. tostring(sale_id) .. "/refund", 288 | params = opts 289 | }) 290 | end, 291 | get_payment = function(self, payment_id) 292 | return self:_request({ 293 | method = "GET", 294 | path = "payments/payment/" .. tostring(payment_id) 295 | }) 296 | end, 297 | get_customer_partner_referral = function(self, partner_referral_id, opts) 298 | assert(partner_referral_id, "missing partner referral id") 299 | return self:_request({ 300 | method = "GET", 301 | path = "customer/partner-referrals/" .. tostring(partner_referral_id), 302 | url_params = opts 303 | }) 304 | end, 305 | get_customer_partner_merchant_integration = function(self, partner_id, merchant_id, opts) 306 | assert(partner_id, "missing partner id") 307 | assert(merchant_id, "missing merchant id") 308 | return self:_request({ 309 | method = "GET", 310 | path = "customer/partners/" .. tostring(partner_id) .. "/merchant-integrations/" .. tostring(merchant_id), 311 | url_params = opts 312 | }) 313 | end, 314 | create_customer_partner_referral = function(self, opts) 315 | return self:_request({ 316 | method = "POST", 317 | path = "customer/partner-referrals", 318 | params = opts 319 | }) 320 | end, 321 | get_customer_disputes = function(self, opts) 322 | return self:_request({ 323 | method = "GET", 324 | path = "customer/disputes", 325 | url_params = opts 326 | }) 327 | end, 328 | get_customer_dispute = function(self, dispute_id, opts) 329 | assert(dispute_id, "missing dispute id") 330 | return self:_request({ 331 | method = "GET", 332 | path = "customer/disputes/" .. tostring(dispute_id), 333 | api_version = opts and opts.api_version, 334 | headers = opts and opts.headers 335 | }) 336 | end, 337 | dispute_accept_claim = function(self, dispute_id, opts) 338 | assert(dispute_id, "missing dispute id") 339 | return self:_request({ 340 | method = "GET", 341 | path = "customer/disputes/" .. tostring(dispute_id) .. "/accept-claim", 342 | params = opts 343 | }) 344 | end, 345 | dispute_escalate = function(self, dispute_id, opts) 346 | assert(dispute_id, "missing dispute id") 347 | return self:_request({ 348 | method = "GET", 349 | path = "customer/disputes/" .. tostring(dispute_id) .. "/escalate", 350 | params = opts 351 | }) 352 | end, 353 | dispute_send_message = function(self, dispute_id, opts) 354 | assert(dispute_id, "missing dispute id") 355 | return self:_request({ 356 | method = "GET", 357 | path = "customer/disputes/" .. tostring(dispute_id) .. "/send-message", 358 | params = opts 359 | }) 360 | end, 361 | create_checkout_order = function(self, params, opts) 362 | return self:_request({ 363 | method = "POST", 364 | path = "checkout/orders", 365 | params = params, 366 | api_version = opts and opts.api_version, 367 | headers = extend({ 368 | ["PayPal-Partner-Attribution-Id"] = self.bn_code 369 | }, opts and opts.headers) 370 | }) 371 | end, 372 | get_order = function(self, order_id, opts) 373 | assert(order_id, "missing order id") 374 | return self:_request({ 375 | method = "GET", 376 | path = "checkout/orders/" .. tostring(order_id), 377 | api_version = opts and opts.api_version 378 | }) 379 | end, 380 | capture_order = function(self, order_id, opts) 381 | assert(order_id, "missing order id") 382 | return self:_request({ 383 | method = "POST", 384 | path = "checkout/orders/" .. tostring(order_id) .. "/capture", 385 | params = { }, 386 | api_version = opts and opts.api_version, 387 | headers = extend({ 388 | ["PayPal-Partner-Attribution-Id"] = self.bn_code 389 | }, opts and opts.headers) 390 | }) 391 | end, 392 | get_capture = function(self, capture_id, opts) 393 | assert(capture_id, "missing capture id") 394 | return self:_request({ 395 | method = "GET", 396 | path = "payments/captures/" .. tostring(capture_id), 397 | api_version = opts and opts.api_version 398 | }) 399 | end, 400 | refund_capture = function(self, capture_id, opts) 401 | assert(capture_id, "missing capture id") 402 | return self:_request({ 403 | method = "POST", 404 | path = "payments/captures/" .. tostring(capture_id) .. "/refund", 405 | params = { }, 406 | api_version = opts and opts.api_version, 407 | headers = opts and opts.headers 408 | }) 409 | end 410 | } 411 | _base_0.__index = _base_0 412 | setmetatable(_base_0, _parent_0.__base) 413 | _class_0 = setmetatable({ 414 | __init = function(self, opts) 415 | if opts == nil then 416 | opts = { } 417 | end 418 | self.sandbox = opts.sandbox or false 419 | self.url = self.sandbox and self.__class.urls.sandbox or self.__class.urls.default 420 | self.client_id = assert(opts.client_id, "missing client id") 421 | self.secret = assert(opts.secret, "missing secret") 422 | self.bn_code = opts.bn_code 423 | if opts.api_version then 424 | self.api_version = opts.api_version 425 | end 426 | self.partner_id = opts.partner_id 427 | return _class_0.__parent.__init(self, opts) 428 | end, 429 | __base = _base_0, 430 | __name = "PayPalRest", 431 | __parent = _parent_0 432 | }, { 433 | __index = function(cls, name) 434 | local val = rawget(_base_0, name) 435 | if val == nil then 436 | local parent = rawget(cls, "__parent") 437 | if parent then 438 | return parent[name] 439 | end 440 | else 441 | return val 442 | end 443 | end, 444 | __call = function(cls, ...) 445 | local _self_0 = setmetatable({}, _base_0) 446 | cls.__init(_self_0, ...) 447 | return _self_0 448 | end 449 | }) 450 | _base_0.__class = _class_0 451 | local self = _class_0 452 | self.urls = { 453 | default = "https://api.paypal.com/", 454 | sandbox = "https://api.sandbox.paypal.com/", 455 | login_default = "https://www.paypal.com/signin/authorize", 456 | login_sandbox = "https://www.sandbox.paypal.com/signin/authorize" 457 | } 458 | if _parent_0.__inherited then 459 | _parent_0.__inherited(_parent_0, _class_0) 460 | end 461 | PayPalRest = _class_0 462 | return _class_0 463 | end 464 | -------------------------------------------------------------------------------- /payments/paypal/rest.moon: -------------------------------------------------------------------------------- 1 | 2 | json = require "cjson" 3 | ltn12 = require "ltn12" 4 | 5 | import format_price, extend from require "payments.paypal.helpers" 6 | 7 | import encode_query_string from require "lapis.util" 8 | 9 | import concat from table 10 | 11 | -- Paypal REST API: 12 | -- https://developer.paypal.com/docs/api/ 13 | class PayPalRest extends require "payments.base_client" 14 | api_version: "v1" 15 | 16 | @urls: { 17 | default: "https://api.paypal.com/" -- + api_version 18 | sandbox: "https://api.sandbox.paypal.com/" -- + api_version 19 | 20 | login_default: "https://www.paypal.com/signin/authorize" 21 | login_sandbox: "https://www.sandbox.paypal.com/signin/authorize" 22 | } 23 | 24 | new: (opts={}) => 25 | @sandbox = opts.sandbox or false 26 | @url = @sandbox and @@urls.sandbox or @@urls.default 27 | @client_id = assert opts.client_id, "missing client id" 28 | @secret = assert opts.secret, "missing secret" 29 | @bn_code = opts.bn_code 30 | 31 | if opts.api_version 32 | @api_version = opts.api_version 33 | 34 | @partner_id = opts.partner_id 35 | 36 | super opts 37 | 38 | url_with_version: (v=@api_version)=> 39 | "#{@url}#{v}/" 40 | 41 | format_price: (...) => format_price ... 42 | 43 | log_in_url: (opts={}) => 44 | url = if @sandbox 45 | @@urls.login_sandbox 46 | else 47 | @@urls.login_default 48 | 49 | params = encode_query_string { 50 | client_id: assert @client_id, "missing client id" 51 | response_type: opts.response_type or "code" 52 | scope: opts.scope or "openid" 53 | redirect_uri: assert opts.redirect_uri, "missing redirect uri" 54 | nonce: opts.nonce 55 | state: opts.state 56 | } 57 | 58 | "#{url}?#{params}" 59 | 60 | identity_token: (opts={}) => 61 | parse_url = require("socket.url").parse 62 | url = "#{@url_with_version "v1"}identity/openidconnect/tokenservice" 63 | host = assert parse_url(url).host 64 | 65 | body = if opts.refresh_token 66 | encode_query_string { 67 | grant_type: "refresh_token" 68 | refresh_token: opts.refresh_token 69 | } 70 | elseif opts.code 71 | encode_query_string { 72 | grant_type: "authorization_code" 73 | code: opts.code 74 | } 75 | else 76 | error "unknown method for identity token (expecting code or refresh_token)" 77 | 78 | import encode_base64 from require "lapis.util.encoding" 79 | 80 | headers = { 81 | "Host": host 82 | "Content-length":tostring(#body) 83 | "Authorization": "Basic #{encode_base64 "#{@client_id}:#{@secret}"}" 84 | "Content-Type": "application/x-www-form-urlencoded" 85 | "Accept": "application/json" 86 | "Accept-Language": "en_US" 87 | } 88 | 89 | out = {} 90 | 91 | res, status = assert @http!.request { 92 | method: "POST" 93 | :url 94 | :headers 95 | 96 | sink: ltn12.sink.table out 97 | source: body and ltn12.source.string(body) or nil 98 | 99 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 100 | } 101 | 102 | out = table.concat out, "" 103 | 104 | if out\match "^{" 105 | out = json.decode out 106 | 107 | unless status == 200 108 | return nil, out 109 | 110 | out 111 | 112 | identity_userinfo: (opts={}) => 113 | assert opts.access_token, "missing access token" 114 | 115 | res, status = @_request { 116 | method: "GET" 117 | path: "oauth2/token/userinfo" 118 | url_params: { 119 | schema: "openid" 120 | } 121 | access_token: opts.access_token 122 | } 123 | 124 | unless status == 200 125 | return nil, res 126 | 127 | res 128 | 129 | need_refresh: => 130 | return true unless @last_token 131 | -- give it a 100 second buffer since who the h*ck knows what time paypal 132 | -- generated the expires for 133 | os.time! > @last_token_time + @last_token.expires_in - 100 134 | 135 | refresh_token: => 136 | return unless @need_refresh! 137 | 138 | import encode_base64 from require "lapis.util.encoding" 139 | 140 | out = {} 141 | 142 | body = encode_query_string grant_type: "client_credentials" 143 | 144 | parse_url = require("socket.url").parse 145 | url = "#{@url_with_version "v1"}oauth2/token" 146 | 147 | host = assert parse_url(url).host 148 | 149 | res, status = assert @http!.request { 150 | :url 151 | method: "POST" 152 | sink: ltn12.sink.table out 153 | source: ltn12.source.string(body) 154 | headers: { 155 | "Host": host 156 | "Content-length": tostring #body 157 | "Authorization": "Basic #{encode_base64 "#{@client_id}:#{@secret}"}" 158 | "Content-Type": "application/x-www-form-urlencoded" 159 | "Accept": "application/json" 160 | "Accept-Language": "en_US" 161 | } 162 | 163 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 164 | } 165 | 166 | @last_token_time = os.time! 167 | @last_token = json.decode concat out 168 | @access_token = @last_token.access_token 169 | assert @access_token, "failed to get token from refresh (#{status})" 170 | 171 | true 172 | 173 | _request: (opts={}) => 174 | assert opts.method, "missing method" 175 | assert opts.path, "missing path" 176 | 177 | {:method, :path, :params, :url_params} = opts 178 | 179 | authorization = if opts.access_token 180 | "Bearer #{opts.access_token}" 181 | else 182 | @refresh_token! 183 | "Bearer #{@access_token}" 184 | 185 | out = {} 186 | 187 | body = if params then json.encode params 188 | 189 | url = "#{@url_with_version opts.api_version}#{path}" 190 | 191 | if url_params 192 | url ..= "?" .. encode_query_string url_params 193 | 194 | parse_url = require("socket.url").parse 195 | host = assert parse_url(@url_with_version!).host 196 | 197 | headers = extend { 198 | "Host": host 199 | "Content-length": body and tostring(#body) or nil 200 | "Authorization": authorization 201 | "Content-Type": body and "application/json" 202 | "Accept": "application/json" 203 | "Accept-Language": "en_US" 204 | }, opts.headers 205 | 206 | res, status = assert @http!.request { 207 | :url 208 | :method 209 | :headers 210 | 211 | sink: ltn12.sink.table out 212 | source: body and ltn12.source.string(body) or nil 213 | 214 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 215 | } 216 | 217 | out = concat out 218 | json.decode(out), status 219 | 220 | get_payments: (opts) => 221 | @_request { 222 | method: "GET" 223 | path: "payments/payment" 224 | params: opts 225 | } 226 | 227 | payout: (opts) => 228 | email = assert opts.email, "missing email" 229 | amount = assert opts.amount, "missing amount" 230 | currency = assert opts.currency, "missing currency" 231 | 232 | assert type(amount) == "string", "amount should be formatted as string (0.00)" 233 | 234 | note = opts.note or "Payout" 235 | email_subject = opts.email_subject or "You got a payout" 236 | 237 | 238 | @_request { 239 | method: "POST" 240 | path: "payments/payouts" 241 | url_params: { 242 | sync_mode: "true" 243 | } 244 | params: { 245 | sender_batch_header: { 246 | :email_subject 247 | } 248 | items: { 249 | { 250 | recipient_type: "EMAIL" 251 | amount: { 252 | value: amount 253 | :currency 254 | } 255 | receiver: email 256 | :note 257 | } 258 | } 259 | } 260 | } 261 | 262 | get_payout: (batch_id) => 263 | -- GET /v1/payments/payouts/ 264 | @_request { 265 | method: "GET" 266 | path: "payments/payouts/#{batch_id}" 267 | } 268 | 269 | get_balance: (params) => 270 | -- GET /v1/reporting/balances 271 | @_request { 272 | method: "GET" 273 | path: "reporting/balances" 274 | url_params: params 275 | } 276 | 277 | get_transaction_report: (params) => 278 | -- GET /v1/reporting/transactions 279 | @_request { 280 | method: "GET" 281 | path: "reporting/transactions" 282 | url_params: params 283 | } 284 | 285 | get_sale_transaction: (transaction_id, opts) => 286 | -- GET /v1/payments/sale/ 287 | @_request { 288 | method: "GET" 289 | path: "payments/sale/#{transaction_id}" 290 | api_version: opts and opts.api_version 291 | } 292 | 293 | -- deprecated method alias 294 | sale_transaction: (...) => @get_sale_transaction ... 295 | 296 | create_payment: (opts) => 297 | @_request { 298 | method: "POST" 299 | path: "payments/payment" 300 | params: opts 301 | } 302 | 303 | execute_payment: (payment_id, opts) => 304 | @_request { 305 | method: "POST" 306 | path: "payments/payment/#{payment_id}/execute" 307 | params: opts 308 | } 309 | 310 | refund_sale: (sale_id, opts={}) => 311 | @_request { 312 | method: "POST" 313 | path: "payments/sale/#{sale_id}/refund" 314 | params: opts 315 | } 316 | 317 | get_payment: (payment_id) => 318 | -- GET /v1/payments/payment/ 319 | @_request { 320 | method: "GET" 321 | path: "payments/payment/#{payment_id}" 322 | } 323 | 324 | get_customer_partner_referral: (partner_referral_id, opts) => 325 | assert partner_referral_id, "missing partner referral id" 326 | @_request { 327 | method: "GET" 328 | path: "customer/partner-referrals/#{partner_referral_id}" 329 | url_params: opts 330 | } 331 | 332 | get_customer_partner_merchant_integration: (partner_id, merchant_id, opts) => 333 | assert partner_id, "missing partner id" 334 | assert merchant_id, "missing merchant id" 335 | 336 | -- /v1/customer/partners/{partner_id}/merchant-integrations/{merchant_id} 337 | @_request { 338 | method: "GET" 339 | path: "customer/partners/#{partner_id}/merchant-integrations/#{merchant_id}" 340 | url_params: opts 341 | } 342 | 343 | -- POST /v1/customer/partner-referrals 344 | create_customer_partner_referral: (opts) => 345 | @_request { 346 | method: "POST" 347 | path: "customer/partner-referrals" 348 | params: opts 349 | } 350 | 351 | get_customer_disputes: (opts) => 352 | @_request { 353 | method: "GET" 354 | path: "customer/disputes" 355 | url_params: opts 356 | } 357 | 358 | get_customer_dispute: (dispute_id, opts) => 359 | assert dispute_id, "missing dispute id" 360 | @_request { 361 | method: "GET" 362 | path: "customer/disputes/#{dispute_id}" 363 | api_version: opts and opts.api_version 364 | headers: opts and opts.headers 365 | } 366 | 367 | -- https://developer.paypal.com/api/customer-disputes/v1/#disputes-actions_accept-claim 368 | dispute_accept_claim: (dispute_id, opts) => 369 | assert dispute_id, "missing dispute id" 370 | @_request { 371 | method: "GET" 372 | path: "customer/disputes/#{dispute_id}/accept-claim" 373 | params: opts 374 | } 375 | 376 | dispute_escalate: (dispute_id, opts) => 377 | assert dispute_id, "missing dispute id" 378 | @_request { 379 | method: "GET" 380 | path: "customer/disputes/#{dispute_id}/escalate" 381 | params: opts 382 | } 383 | 384 | -- https://developer.paypal.com/api/customer-disputes/v1/#disputes-actions_send-message 385 | dispute_send_message: (dispute_id, opts) => 386 | assert dispute_id, "missing dispute id" 387 | @_request { 388 | method: "GET" 389 | path: "customer/disputes/#{dispute_id}/send-message" 390 | params: opts 391 | } 392 | 393 | create_checkout_order: (params, opts) => 394 | @_request { 395 | method: "POST" 396 | path: "checkout/orders" 397 | params: params 398 | api_version: opts and opts.api_version 399 | headers: extend { 400 | "PayPal-Partner-Attribution-Id": @bn_code 401 | }, opts and opts.headers 402 | } 403 | 404 | get_order: (order_id, opts) => 405 | assert order_id, "missing order id" 406 | @_request { 407 | method: "GET" 408 | path: "checkout/orders/#{order_id}" 409 | api_version: opts and opts.api_version 410 | } 411 | 412 | capture_order: (order_id, opts) => 413 | assert order_id, "missing order id" 414 | 415 | @_request { 416 | method: "POST" 417 | path: "checkout/orders/#{order_id}/capture" 418 | params: {} 419 | api_version: opts and opts.api_version 420 | headers: extend { 421 | "PayPal-Partner-Attribution-Id": @bn_code 422 | }, opts and opts.headers 423 | } 424 | 425 | get_capture: (capture_id, opts) => 426 | assert capture_id, "missing capture id" 427 | @_request { 428 | method: "GET" 429 | path: "payments/captures/#{capture_id}" 430 | api_version: opts and opts.api_version 431 | } 432 | 433 | refund_capture: (capture_id, opts) => 434 | assert capture_id, "missing capture id" 435 | @_request { 436 | method: "POST" 437 | path: "payments/captures/#{capture_id}/refund" 438 | params: {} 439 | api_version: opts and opts.api_version 440 | headers: opts and opts.headers 441 | } 442 | -------------------------------------------------------------------------------- /payments/stripe.lua: -------------------------------------------------------------------------------- 1 | local ltn12 = require("ltn12") 2 | local json = require("cjson") 3 | local encode_query_string, parse_query_string 4 | do 5 | local _obj_0 = require("lapis.util") 6 | encode_query_string, parse_query_string = _obj_0.encode_query_string, _obj_0.parse_query_string 7 | end 8 | local encode_base64 9 | encode_base64 = require("lapis.util.encoding").encode_base64 10 | local Stripe 11 | do 12 | local _class_0 13 | local resource 14 | local _parent_0 = require("payments.base_client") 15 | local _base_0 = { 16 | api_url = "https://api.stripe.com/v1/", 17 | for_account_id = function(self, account_id) 18 | local out = Stripe({ 19 | client_id = self.client_id, 20 | client_secret = self.client_secret, 21 | publishable_key = self.publishable_key, 22 | stripe_account_id = account_id, 23 | stripe_version = self.stripe_version, 24 | http_provider = self.http_provider 25 | }) 26 | out.http = self.http 27 | return out 28 | end, 29 | calculate_fee = function(self, currency, transactions_count, amount, medium) 30 | local _exp_0 = medium 31 | if "default" == _exp_0 then 32 | return transactions_count * 30 + math.floor(amount * 0.029) 33 | elseif "bitcoin" == _exp_0 then 34 | return math.floor(amount * 0.005) 35 | else 36 | return error("don't know how to calculate stripe fee for medium " .. tostring(medium)) 37 | end 38 | end, 39 | connect_url = function(self) 40 | return "https://connect.stripe.com/oauth/authorize?response_type=code&scope=read_write&client_id=" .. tostring(self.client_id) 41 | end, 42 | oauth_token = function(self, code) 43 | local out = { } 44 | local parse_url = require("socket.url").parse 45 | local body = encode_query_string({ 46 | code = code, 47 | client_secret = self.client_secret, 48 | grant_type = "authorization_code" 49 | }) 50 | local connect_url = "https://connect.stripe.com/oauth/token" 51 | local _, status = assert(self:http().request({ 52 | url = connect_url, 53 | method = "POST", 54 | sink = ltn12.sink.table(out), 55 | headers = { 56 | ["Host"] = assert(parse_url(connect_url).host, "failed to get host"), 57 | ["Content-Type"] = "application/x-www-form-urlencoded", 58 | ["Content-length"] = body and tostring(#body) or nil 59 | }, 60 | source = ltn12.source.string(body), 61 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 62 | })) 63 | out = table.concat(out) 64 | if not (status == 200) then 65 | return nil, "got status " .. tostring(status) .. ": " .. tostring(out) 66 | end 67 | return json.decode(out) 68 | end, 69 | _request = function(self, method, path, params, access_token, more_headers) 70 | if access_token == nil then 71 | access_token = self.client_secret 72 | end 73 | local out = { } 74 | if params then 75 | for k, v in pairs(params) do 76 | params[k] = tostring(v) 77 | end 78 | end 79 | local body 80 | if method ~= "GET" then 81 | body = params and encode_query_string(params) 82 | end 83 | local parse_url = require("socket.url").parse 84 | local headers = { 85 | ["Host"] = assert(parse_url(self.api_url).host, "failed to get host"), 86 | ["Authorization"] = "Basic " .. encode_base64(access_token .. ":"), 87 | ["Content-Type"] = "application/x-www-form-urlencoded", 88 | ["Content-length"] = body and tostring(#body) or nil, 89 | ["Stripe-Account"] = self.stripe_account_id, 90 | ["Stripe-Version"] = self.stripe_version 91 | } 92 | if more_headers then 93 | for k, v in pairs(more_headers) do 94 | headers[k] = v 95 | end 96 | end 97 | local url = self.api_url .. path 98 | if method == "GET" and params then 99 | url = url .. "?" .. tostring(encode_query_string(params)) 100 | end 101 | local _, status = self:http().request({ 102 | url = url, 103 | method = method, 104 | headers = headers, 105 | sink = ltn12.sink.table(out), 106 | source = body and ltn12.source.string(body) or nil, 107 | protocol = self.http_provider == "ssl.https" and "sslv23" or nil 108 | }) 109 | return self:_format_response(json.decode(table.concat(out)), status) 110 | end, 111 | _format_response = function(self, res, status) 112 | if res.error then 113 | return nil, res.error.message, res, status 114 | else 115 | return res, status 116 | end 117 | end, 118 | _iterate_resource = function(self, method, opts) 119 | local last_id = opts and opts.starting_after 120 | return coroutine.wrap(function() 121 | while true do 122 | local iteration_opts = { 123 | limit = 50, 124 | starting_after = last_id 125 | } 126 | if opts then 127 | for k, v in pairs(opts) do 128 | local _continue_0 = false 129 | repeat 130 | if k == "starting_after" then 131 | _continue_0 = true 132 | break 133 | end 134 | iteration_opts[k] = v 135 | _continue_0 = true 136 | until true 137 | if not _continue_0 then 138 | break 139 | end 140 | end 141 | end 142 | local items = assert(method(self, iteration_opts)) 143 | local _list_0 = items.data 144 | for _index_0 = 1, #_list_0 do 145 | local a = _list_0[_index_0] 146 | last_id = a.id 147 | coroutine.yield(a) 148 | end 149 | if not (items.has_more) then 150 | break 151 | end 152 | if not (last_id) then 153 | break 154 | end 155 | end 156 | end) 157 | end, 158 | charge = function(self, opts) 159 | local access_token, card, customer, source, amount, currency, description, fee 160 | access_token, card, customer, source, amount, currency, description, fee = opts.access_token, opts.card, opts.customer, opts.source, opts.amount, opts.currency, opts.description, opts.fee 161 | assert(tonumber(amount), "missing amount") 162 | local application_fee 163 | if fee and fee > 0 then 164 | application_fee = fee 165 | end 166 | return self:_request("POST", "charges", { 167 | card = card, 168 | customer = customer, 169 | source = source, 170 | amount = amount, 171 | description = description, 172 | currency = currency, 173 | application_fee = application_fee 174 | }, access_token) 175 | end, 176 | create_card = function(self, customer_id, opts) 177 | return self:_request("POST", "customers/" .. tostring(customer_id) .. "/sources", opts) 178 | end, 179 | update_card = function(self, customer_id, card_id, opts) 180 | return self:_request("POST", "customers/" .. tostring(customer_id) .. "/sources/" .. tostring(card_id), opts) 181 | end, 182 | get_card = function(self, customer_id, card_id, opts) 183 | return self:_request("GET", "customers/" .. tostring(customer_id) .. "/sources/" .. tostring(card_id), opts) 184 | end, 185 | delete_customer_card = function(self, customer_id, card_id, opts) 186 | return self:_request("DELETE", "customers/" .. tostring(customer_id) .. "/sources/" .. tostring(card_id), opts) 187 | end, 188 | get_token = function(self, token_id) 189 | return self:_request("GET", "tokens/" .. tostring(token_id)) 190 | end, 191 | refund_charge = function(self, charge_id) 192 | return self:_request("POST", "refunds", { 193 | charge = charge_id 194 | }) 195 | end, 196 | mark_fraud = function(self, charge_id) 197 | return self:_request("POST", "charges/" .. tostring(charge_id), { 198 | ["fraud_details[user_report]"] = "fraudulent" 199 | }) 200 | end, 201 | transfer = function(self, destination, currency, amount) 202 | assert("USD" == currency, "usd only for now") 203 | assert(tonumber(amount), "invalid amount") 204 | return self:_request("POST", "transfers", { 205 | destination = destination, 206 | currency = currency, 207 | amount = amount 208 | }) 209 | end, 210 | fill_test_balance = function(self, amount) 211 | if amount == nil then 212 | amount = 50000 213 | end 214 | assert(self.client_secret:match("^sk_test_"), "can only fill account in test") 215 | return self:_request("POST", "charges", { 216 | amount = amount, 217 | currency = "USD", 218 | ["source[object]"] = "card", 219 | ["source[number]"] = "4000000000000077", 220 | ["source[exp_month]"] = "2", 221 | ["source[exp_year]"] = "22" 222 | }) 223 | end, 224 | get_balance = function(self) 225 | return self:_request("GET", "balance") 226 | end, 227 | create_checkout_session = function(self, opts) 228 | return self:_request("POST", "checkout/sessions", opts) 229 | end, 230 | search = function(self, ...) 231 | return self:_request("GET", "search", ...) 232 | end 233 | } 234 | _base_0.__index = _base_0 235 | setmetatable(_base_0, _parent_0.__base) 236 | _class_0 = setmetatable({ 237 | __init = function(self, opts) 238 | self.client_id = assert(opts.client_id, "missing client id") 239 | self.client_secret = assert(opts.client_secret, "missing client secret") 240 | self.publishable_key = opts.publishable_key 241 | self.stripe_account_id = opts.stripe_account_id 242 | self.stripe_version = opts.stripe_version 243 | return _class_0.__parent.__init(self, opts) 244 | end, 245 | __base = _base_0, 246 | __name = "Stripe", 247 | __parent = _parent_0 248 | }, { 249 | __index = function(cls, name) 250 | local val = rawget(_base_0, name) 251 | if val == nil then 252 | local parent = rawget(cls, "__parent") 253 | if parent then 254 | return parent[name] 255 | end 256 | else 257 | return val 258 | end 259 | end, 260 | __call = function(cls, ...) 261 | local _self_0 = setmetatable({}, _base_0) 262 | cls.__init(_self_0, ...) 263 | return _self_0 264 | end 265 | }) 266 | _base_0.__class = _class_0 267 | local self = _class_0 268 | resource = function(name, resource_opts) 269 | if resource_opts == nil then 270 | resource_opts = { } 271 | end 272 | local singular = resource_opts.singular or name:gsub("s$", "") 273 | local api_path = resource_opts.path or name 274 | local list_method = "list_" .. tostring(name) 275 | if not (resource_opts.get == false) then 276 | local _update_0 = list_method 277 | self.__base[_update_0] = self.__base[_update_0] or function(self, ...) 278 | return self:_request("GET", api_path, ...) 279 | end 280 | local _update_1 = "each_" .. tostring(singular) 281 | self.__base[_update_1] = self.__base[_update_1] or function(self, ...) 282 | return self:_iterate_resource(self[list_method], ...) 283 | end 284 | local _update_2 = "get_" .. tostring(singular) 285 | self.__base[_update_2] = self.__base[_update_2] or function(self, id, ...) 286 | return self:_request("GET", tostring(api_path) .. "/" .. tostring(id), ...) 287 | end 288 | end 289 | if not (resource_opts.edit == false) then 290 | local _update_0 = "update_" .. tostring(singular) 291 | self.__base[_update_0] = self.__base[_update_0] or function(self, id, opts, ...) 292 | if resource_opts.update then 293 | opts = resource_opts.update(self, opts) 294 | end 295 | return self:_request("POST", tostring(api_path) .. "/" .. tostring(id), opts, ...) 296 | end 297 | local _update_1 = "delete_" .. tostring(singular) 298 | self.__base[_update_1] = self.__base[_update_1] or function(self, id) 299 | return self:_request("DELETE", tostring(api_path) .. "/" .. tostring(id)) 300 | end 301 | local _update_2 = "create_" .. tostring(singular) 302 | self.__base[_update_2] = self.__base[_update_2] or function(self, opts, ...) 303 | if resource_opts.create then 304 | opts = resource_opts.create(self, opts) 305 | end 306 | return self:_request("POST", api_path, opts, ...) 307 | end 308 | end 309 | end 310 | resource("accounts", { 311 | create = function(self, opts) 312 | if opts.managed == nil then 313 | opts.managed = true 314 | end 315 | assert(opts.country, "missing country") 316 | assert(opts.email, "missing country") 317 | return opts 318 | end 319 | }) 320 | resource("customers") 321 | resource("charges") 322 | resource("disputes", { 323 | edit = false 324 | }) 325 | resource("refunds", { 326 | edit = false 327 | }) 328 | resource("transfers", { 329 | edit = false 330 | }) 331 | resource("balance_transactions", { 332 | edit = false, 333 | path = "balance/history" 334 | }) 335 | resource("application_fees", { 336 | edit = false 337 | }) 338 | resource("events", { 339 | edit = false 340 | }) 341 | resource("bitcoin_receivers", { 342 | edit = false, 343 | path = "bitcoin/receivers" 344 | }) 345 | resource("products") 346 | resource("plans") 347 | resource("subscriptions") 348 | resource("invoices", { 349 | edit = false 350 | }) 351 | resource("upcoming_invoices", { 352 | edit = false, 353 | path = "invoices/upcoming" 354 | }) 355 | resource("coupons") 356 | if _parent_0.__inherited then 357 | _parent_0.__inherited(_parent_0, _class_0) 358 | end 359 | Stripe = _class_0 360 | end 361 | return { 362 | Stripe = Stripe 363 | } 364 | -------------------------------------------------------------------------------- /payments/stripe.moon: -------------------------------------------------------------------------------- 1 | 2 | ltn12 = require "ltn12" 3 | json = require "cjson" 4 | 5 | import encode_query_string, parse_query_string from require "lapis.util" 6 | 7 | import encode_base64 from require "lapis.util.encoding" 8 | 9 | class Stripe extends require "payments.base_client" 10 | api_url: "https://api.stripe.com/v1/" 11 | 12 | resource = (name, resource_opts={}) -> 13 | singular = resource_opts.singular or name\gsub "s$", "" 14 | api_path = resource_opts.path or name 15 | 16 | list_method = "list_#{name}" 17 | 18 | unless resource_opts.get == false 19 | @__base[list_method] or= (...) => 20 | @_request "GET", api_path, ... 21 | 22 | @__base["each_#{singular}"] or= (...) => 23 | @_iterate_resource @[list_method], ... 24 | 25 | @__base["get_#{singular}"] or= (id, ...) => 26 | @_request "GET", "#{api_path}/#{id}", ... 27 | 28 | unless resource_opts.edit == false 29 | @__base["update_#{singular}"] or= (id, opts, ...) => 30 | if resource_opts.update 31 | opts = resource_opts.update @, opts 32 | 33 | @_request "POST", "#{api_path}/#{id}", opts, ... 34 | 35 | @__base["delete_#{singular}"] or= (id) => 36 | @_request "DELETE", "#{api_path}/#{id}" 37 | 38 | @__base["create_#{singular}"] or= (opts, ...) => 39 | if resource_opts.create 40 | opts = resource_opts.create @, opts 41 | 42 | @_request "POST", api_path, opts, ... 43 | 44 | new: (opts) => 45 | @client_id = assert opts.client_id, "missing client id" 46 | @client_secret = assert opts.client_secret, "missing client secret" 47 | @publishable_key = opts.publishable_key 48 | @stripe_account_id = opts.stripe_account_id 49 | @stripe_version = opts.stripe_version 50 | super opts 51 | 52 | for_account_id: (account_id) => 53 | out = Stripe { 54 | client_id: @client_id 55 | client_secret: @client_secret 56 | publishable_key: @publishable_key 57 | stripe_account_id: account_id 58 | stripe_version: @stripe_version 59 | http_provider: @http_provider 60 | } 61 | 62 | out.http = @http 63 | 64 | out 65 | 66 | calculate_fee: (currency, transactions_count, amount, medium) => 67 | switch medium 68 | when "default" 69 | -- 2.9% + $0.30 per transaction 70 | -- https://stripe.com/us/pricing 71 | transactions_count * 30 + math.floor(amount * 0.029) 72 | when "bitcoin" 73 | -- 0.5% per transaction, no other fee 74 | -- https://stripe.com/bitcoin 75 | math.floor(amount * 0.005) 76 | else 77 | error "don't know how to calculate stripe fee for medium #{medium}" 78 | 79 | -- TODO: use csrf 80 | connect_url: => 81 | "https://connect.stripe.com/oauth/authorize?response_type=code&scope=read_write&client_id=#{@client_id}" 82 | 83 | -- converts auth code into access token 84 | -- Returns: 85 | -- { 86 | -- "access_token": "sk_test_xxx", 87 | -- "livemode": false, 88 | -- "refresh_token": "rt_xxx", 89 | -- "token_type": "bearer", 90 | -- "stripe_publishable_key": "pk_test_xxx", 91 | -- "stripe_user_id": "acct_xxx", 92 | -- "scope": "read_only" 93 | -- } 94 | oauth_token: (code) => 95 | out = {} 96 | 97 | parse_url = require("socket.url").parse 98 | 99 | body = encode_query_string { 100 | :code 101 | client_secret: @client_secret 102 | grant_type: "authorization_code" 103 | } 104 | 105 | connect_url = "https://connect.stripe.com/oauth/token" 106 | 107 | _, status = assert @http!.request { 108 | url: connect_url 109 | method: "POST" 110 | sink: ltn12.sink.table out 111 | headers: { 112 | "Host": assert parse_url(connect_url).host, "failed to get host" 113 | "Content-Type": "application/x-www-form-urlencoded" 114 | "Content-length": body and tostring(#body) or nil 115 | } 116 | 117 | source: ltn12.source.string body 118 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 119 | } 120 | 121 | out = table.concat out 122 | 123 | unless status == 200 124 | return nil, "got status #{status}: #{out}" 125 | 126 | json.decode out 127 | 128 | _request: (method, path, params, access_token=@client_secret, more_headers) => 129 | out = {} 130 | 131 | if params 132 | for k,v in pairs params 133 | params[k] = tostring v 134 | 135 | body = if method != "GET" 136 | params and encode_query_string params 137 | 138 | parse_url = require("socket.url").parse 139 | 140 | headers = { 141 | "Host": assert parse_url(@api_url).host, "failed to get host" 142 | "Authorization": "Basic " .. encode_base64 access_token .. ":" 143 | "Content-Type": "application/x-www-form-urlencoded" 144 | "Content-length": body and tostring(#body) or nil 145 | "Stripe-Account": @stripe_account_id 146 | "Stripe-Version": @stripe_version 147 | } 148 | 149 | if more_headers 150 | for k,v in pairs more_headers 151 | headers[k] = v 152 | 153 | url = @api_url .. path 154 | if method == "GET" and params 155 | url ..= "?#{encode_query_string params}" 156 | 157 | _, status = @http!.request { 158 | :url 159 | :method 160 | :headers 161 | sink: ltn12.sink.table out 162 | source: body and ltn12.source.string(body) or nil 163 | 164 | protocol: @http_provider == "ssl.https" and "sslv23" or nil 165 | } 166 | 167 | @_format_response json.decode(table.concat out), status 168 | 169 | _format_response: (res, status) => 170 | if res.error 171 | nil, res.error.message, res, status 172 | else 173 | res, status 174 | 175 | _iterate_resource: (method, opts) => 176 | last_id = opts and opts.starting_after 177 | 178 | coroutine.wrap -> 179 | while true 180 | iteration_opts = { 181 | limit: 50 182 | starting_after: last_id 183 | } 184 | 185 | if opts 186 | for k, v in pairs opts 187 | continue if k == "starting_after" -- don't copy over initial starting point 188 | iteration_opts[k] = v 189 | 190 | items = assert method @, iteration_opts 191 | 192 | for a in *items.data 193 | last_id = a.id 194 | coroutine.yield a 195 | 196 | break unless items.has_more 197 | break unless last_id 198 | 199 | resource "accounts", { 200 | create: (opts) => 201 | opts.managed = true if opts.managed == nil 202 | assert opts.country, "missing country" 203 | assert opts.email, "missing country" 204 | 205 | opts 206 | } 207 | 208 | resource "customers" 209 | 210 | resource "charges" 211 | resource "disputes", edit: false 212 | resource "refunds", edit: false 213 | resource "transfers", edit: false 214 | resource "balance_transactions", edit: false, path: "balance/history" 215 | resource "application_fees", edit: false 216 | resource "events", edit: false 217 | resource "bitcoin_receivers", edit: false, path: "bitcoin/receivers" 218 | 219 | resource "products" 220 | resource "plans" 221 | resource "subscriptions" 222 | resource "invoices", edit: false 223 | resource "upcoming_invoices", edit: false, path: "invoices/upcoming" 224 | resource "coupons" 225 | 226 | -- NOTE: this is deprecated method, use the create_charge method part of the charges resource 227 | -- NOTE: how fee is renamed to application_fee 228 | -- NOTE: how access_token is extracted 229 | -- charge a card with amount cents 230 | charge: (opts) => 231 | { 232 | :access_token, :card, :customer, :source, :amount, :currency, 233 | :description, :fee 234 | } = opts 235 | 236 | assert tonumber(amount), "missing amount" 237 | 238 | application_fee = if fee and fee > 0 then fee 239 | 240 | @_request "POST", "charges", { 241 | :card, :customer, :source, :amount, :description, :currency, 242 | :application_fee 243 | }, access_token 244 | 245 | create_card: (customer_id, opts) => 246 | @_request "POST", "customers/#{customer_id}/sources", opts 247 | 248 | update_card: (customer_id, card_id, opts) => 249 | @_request "POST", "customers/#{customer_id}/sources/#{card_id}", opts 250 | 251 | get_card: (customer_id, card_id, opts) => 252 | @_request "GET", "customers/#{customer_id}/sources/#{card_id}", opts 253 | 254 | delete_customer_card: (customer_id, card_id, opts) => 255 | @_request "DELETE", "customers/#{customer_id}/sources/#{card_id}", opts 256 | 257 | get_token: (token_id) => 258 | @_request "GET", "tokens/#{token_id}" 259 | 260 | refund_charge: (charge_id) => 261 | @_request "POST", "refunds", { 262 | charge: charge_id 263 | } 264 | 265 | mark_fraud: (charge_id) => 266 | @_request "POST", "charges/#{charge_id}", { 267 | "fraud_details[user_report]": "fraudulent" 268 | } 269 | 270 | transfer: (destination, currency, amount) => 271 | assert "USD" == currency, "usd only for now" 272 | assert tonumber(amount), "invalid amount" 273 | 274 | @_request "POST", "transfers", { 275 | :destination 276 | :currency 277 | :amount 278 | } 279 | 280 | -- this is just for development to fill the test account 281 | fill_test_balance: (amount=50000) => 282 | assert @client_secret\match("^sk_test_"), "can only fill account in test" 283 | 284 | @_request "POST", "charges", { 285 | :amount 286 | currency: "USD" 287 | "source[object]": "card" 288 | "source[number]": "4000000000000077" 289 | "source[exp_month]": "2" 290 | "source[exp_year]": "22" 291 | } 292 | 293 | get_balance: => 294 | @_request "GET", "balance" 295 | 296 | create_checkout_session: (opts) => 297 | @_request "POST", "checkout/sessions", opts 298 | 299 | -- this appears to be an undocumented api: https://stripe.com/docs/search-beta/api-details 300 | search: (...) => 301 | @_request "GET", "search", ... 302 | 303 | { :Stripe } 304 | -------------------------------------------------------------------------------- /spec/helpers.moon: -------------------------------------------------------------------------------- 1 | 2 | import types from require "tableshape" 3 | import parse_query_string from require "lapis.util" 4 | 5 | url = require "socket.url" 6 | 7 | assert_shape = (obj, shape) -> 8 | assert shape obj 9 | 10 | -- returns shape that matches against parsed url 11 | url_shape = (obj, _t=types.partial) -> 12 | format_error = (value, err) => 13 | import to_json from require "lapis.util" 14 | "got #{to_json value}: #{err}" 15 | 16 | annotated = {k, types.annotate(v, :format_error) for k,v in pairs obj} 17 | types.string / url.parse * _t annotated 18 | 19 | query_string_shape = (obj, _t=types.shape) -> 20 | -- since query string returns parsed result in two ways, we strip it out 21 | -- into plain hash table 22 | 23 | types.string / 24 | parse_query_string / 25 | ((o) -> { k,v for k,v in pairs o when type(k) == "string"}) * 26 | _t obj 27 | 28 | extract_params = (str) -> 29 | params = assert parse_query_string str 30 | {k,v for k,v in pairs params when type(k) == "string"} 31 | 32 | make_http = (handle) -> 33 | 34 | http_requests = {} 35 | fn = => 36 | @http_provider = "test" 37 | { 38 | request: (req) -> 39 | table.insert http_requests, req 40 | handle req if handle 41 | 1, 200, {} 42 | } 43 | 44 | fn, http_requests 45 | 46 | {:extract_params, :make_http, :assert_shape, :url_shape, :query_string_shape} 47 | -------------------------------------------------------------------------------- /spec/paypal/adaptive_spec.moon: -------------------------------------------------------------------------------- 1 | import types from require "tableshape" 2 | import extract_params, make_http, assert_shape from require "spec.helpers" 3 | 4 | describe "paypal", -> 5 | describe "adaptive payments", -> 6 | local http_requests, http_fn 7 | 8 | before_each -> 9 | http_fn, http_requests = make_http! 10 | 11 | describe "with client", -> 12 | local paypal 13 | 14 | assert_request = (request, req_shape, params_shape) -> 15 | assert request, "missing request" 16 | 17 | test_req_shape = { 18 | headers: types.shape { 19 | Host: "svcs.sandbox.paypal.com" 20 | "X-PAYPAL-RESPONSE-DATA-FORMAT": "NV" 21 | "X-PAYPAL-APPLICATION-ID": "APP-1234HELLOWORLD" 22 | "X-PAYPAL-SECURITY-USERID": "me_1212121.leafo.net" 23 | "X-PAYPAL-SECURITY-SIGNATURE": "AABBBC_CCZZZXXX" 24 | "X-PAYPAL-SECURITY-PASSWORD": "123456789" 25 | "Content-length": types.pattern "%d+" 26 | "X-PAYPAL-REQUEST-DATA-FORMAT": "NV" 27 | } 28 | } 29 | 30 | if req_shape 31 | for k,v in pairs req_shape 32 | test_req_shape[k] = v 33 | 34 | assert_shape request, types.shape test_req_shape, open: true 35 | 36 | if params_shape 37 | params = extract_params request.source! 38 | assert_shape params, params_shape 39 | 40 | before_each -> 41 | import PayPalAdaptive from require "payments.paypal" 42 | paypal = PayPalAdaptive { 43 | sandbox: true 44 | application_id: "APP-1234HELLOWORLD" 45 | auth: { 46 | USER: "me_1212121.leafo.net" 47 | PWD: "123456789" 48 | SIGNATURE: "AABBBC_CCZZZXXX" 49 | } 50 | } 51 | paypal.http = http_fn 52 | 53 | it "makes pay request", -> 54 | paypal\pay { 55 | cancelUrl: "http://leafo.net/cancel" 56 | returnUrl: "http://leafo.net/return" 57 | currencyCode: "EUR" 58 | receivers: { 59 | { 60 | email: "me@example.com" 61 | amount: "5.50" 62 | primary: true 63 | }, 64 | { 65 | email: "you@example.com" 66 | amount: "1.50" 67 | } 68 | } 69 | } 70 | 71 | assert.same 1, #http_requests 72 | request = http_requests[1] 73 | 74 | assert_request request, { 75 | method: "POST" 76 | url: "https://svcs.sandbox.paypal.com/AdaptivePayments/Pay" 77 | }, types.shape { 78 | actionType: "PAY" 79 | feesPayer: "PRIMARYRECEIVER" 80 | currencyCode: "EUR" 81 | cancelUrl: "http://leafo.net/cancel" 82 | returnUrl: "http://leafo.net/return" 83 | 84 | "requestEnvelope.errorLanguage": "en_US" 85 | "clientDetails.applicationId": "APP-1234HELLOWORLD" 86 | "receiverList.receiver(0).amount": "5.50" 87 | "receiverList.receiver(0).email": "me@example.com" 88 | "receiverList.receiver(0).primary": "true" 89 | "receiverList.receiver(1).amount": "1.50" 90 | "receiverList.receiver(1).email": "you@example.com" 91 | } 92 | 93 | it "makes convert currency request", -> 94 | paypal\convert_currency "5.00", "USD", "EUR" 95 | assert_request http_requests[1], { 96 | method: "POST" 97 | url: "https://svcs.sandbox.paypal.com/AdaptivePayments/ConvertCurrency" 98 | }, types.shape { 99 | "requestEnvelope.errorLanguage": "en_US" 100 | "clientDetails.applicationId": "APP-1234HELLOWORLD" 101 | 102 | "baseAmountList.currency(0).code": "USD" 103 | "baseAmountList.currency(0).amount": "5.00" 104 | "convertToCurrencyList.currencyCode": "EUR" 105 | } 106 | 107 | it "makes refund request", -> 108 | paypal\refund "my-key-1000" 109 | 110 | assert_request http_requests[1], { 111 | method: "POST" 112 | url: "https://svcs.sandbox.paypal.com/AdaptivePayments/Refund" 113 | }, types.shape { 114 | "requestEnvelope.errorLanguage": "en_US" 115 | "clientDetails.applicationId": "APP-1234HELLOWORLD" 116 | payKey: "my-key-1000" 117 | } 118 | 119 | it "gets payment details", -> 120 | paypal\payment_details payKey: "hello-world" 121 | 122 | assert_request http_requests[1], { 123 | method: "POST" 124 | url: "https://svcs.sandbox.paypal.com/AdaptivePayments/PaymentDetails" 125 | }, types.shape { 126 | "requestEnvelope.errorLanguage": "en_US" 127 | "clientDetails.applicationId": "APP-1234HELLOWORLD" 128 | payKey: "hello-world" 129 | } 130 | 131 | it "makes sets payment options", -> 132 | paypal\set_payment_options "my-key-1001", { 133 | "displayOptions.businessName": "some title" 134 | } 135 | 136 | assert_request http_requests[1], { 137 | method: "POST" 138 | url: "https://svcs.sandbox.paypal.com/AdaptivePayments/SetPaymentOptions" 139 | }, types.shape { 140 | "requestEnvelope.errorLanguage": "en_US" 141 | "clientDetails.applicationId": "APP-1234HELLOWORLD" 142 | "displayOptions.businessName": "some title" 143 | payKey: "my-key-1001" 144 | } 145 | 146 | 147 | it "creates checkout url", -> 148 | assert.same "https://www.sandbox.paypal.com/webscr?cmd=_ap-payment&paykey=hello-world", paypal\checkout_url "hello-world" 149 | 150 | 151 | -------------------------------------------------------------------------------- /spec/paypal/express_spec.moon: -------------------------------------------------------------------------------- 1 | import types from require "tableshape" 2 | import parse_query_string from require "lapis.util" 3 | import extract_params, make_http, assert_shape from require "spec.helpers" 4 | 5 | describe "paypal", -> 6 | describe "express checkout", -> 7 | local http_requests, http_fn 8 | 9 | before_each -> 10 | http_fn, http_requests = make_http! 11 | 12 | describe "with client", -> 13 | local paypal 14 | 15 | assert_request = -> 16 | assert.same 1, #http_requests 17 | 18 | assert_shape http_requests[1], types.shape { 19 | method: "POST" 20 | url: "https://api-3t.sandbox.paypal.com/nvp" 21 | source: types.function 22 | sink: types.function 23 | headers: types.shape { 24 | Host: "api-3t.sandbox.paypal.com" 25 | "Content-type": "application/x-www-form-urlencoded" 26 | "Content-length": types.pattern "%d+" 27 | } 28 | } 29 | 30 | http_requests[1] 31 | 32 | assert_params = (request, shape) -> 33 | params = extract_params assert(request.source, "missing source")! 34 | assert_shape params, shape 35 | 36 | before_each -> 37 | import PayPalExpressCheckout from require "payments.paypal" 38 | paypal = PayPalExpressCheckout { 39 | sandbox: true 40 | 41 | auth: { 42 | USER: "me_1212121.leafo.net" 43 | PWD: "123456789" 44 | SIGNATURE: "AABBBC_CCZZZXXX" 45 | } 46 | } 47 | 48 | paypal.http = http_fn 49 | 50 | it "call sets_express_checkout", -> 51 | paypal\set_express_checkout { 52 | returnurl: "http://leafo.net/success" 53 | cancelurl: "http://leafo.net/cancel" 54 | brandname: "Purchase something" 55 | paymentrequest_0_amt: "$5.99" 56 | } 57 | 58 | request = assert_request! 59 | assert_params request, types.shape { 60 | PAYMENTREQUEST_0_AMT: "$5.99" 61 | CANCELURL: "http://leafo.net/cancel" 62 | RETURNURL: "http://leafo.net/success" 63 | BRANDNAME: "Purchase something" 64 | PWD: "123456789" 65 | SIGNATURE: "AABBBC_CCZZZXXX" 66 | USER: "me_1212121.leafo.net" 67 | VERSION: "98" 68 | METHOD: "SetExpressCheckout" 69 | } 70 | 71 | it "gets checkout url", -> 72 | out = paypal\checkout_url "toekn-abc123" 73 | 74 | 75 | -------------------------------------------------------------------------------- /spec/paypal/rest_spec.moon: -------------------------------------------------------------------------------- 1 | import types from require "tableshape" 2 | import extract_params, make_http, assert_shape from require "spec.helpers" 3 | 4 | describe "paypal", -> 5 | describe "rest", -> 6 | describe "with client", -> 7 | local paypal, http_requests, http_fn 8 | 9 | before_each -> 10 | import PayPalRest from require "payments.paypal" 11 | http_fn, http_requests = make_http (req) -> 12 | json = require "cjson" 13 | req.sink json.encode { 14 | access_token: "ACCESS_TOKEN" 15 | expires_in: 100 16 | } 17 | 18 | paypal = PayPalRest { 19 | client_id: "123" 20 | secret: "shh" 21 | } 22 | 23 | paypal.http = http_fn 24 | 25 | assert_oauth_token_request = (req) -> 26 | assert (types.shape { 27 | method: "POST" 28 | source: types.function 29 | sink: types.function 30 | url: "https://api.paypal.com/v1/oauth2/token" 31 | headers: types.shape { 32 | Host: "api.paypal.com" 33 | Accept: "application/json" 34 | Authorization: "Basic MTIzOnNoaA==" 35 | "Content-length": "29" 36 | "Content-Type": "application/x-www-form-urlencoded" 37 | "Accept-Language": "en_US" 38 | } 39 | }) req 40 | 41 | assert.same { 42 | grant_type: "client_credentials" 43 | }, extract_params req.source! 44 | 45 | assert_api_requrest = (req, opts) -> 46 | assert (types.shape { 47 | method: opts.method 48 | sink: types.function 49 | source: opts.body and types.function 50 | url: opts.url 51 | headers: types.shape { 52 | Host: "api.paypal.com" 53 | Accept: "application/json" 54 | Authorization: "Bearer ACCESS_TOKEN" 55 | "Content-Type": if req.method == "POST" 56 | "application/json" 57 | "Accept-Language": "en_US" 58 | "Content-length": types.pattern("%d+")\is_optional! 59 | } 60 | }) req 61 | 62 | if opts.body 63 | source = req.source! 64 | import from_json from require "lapis.util" 65 | assert.same opts.body, from_json(source) 66 | 67 | it "makes request", -> 68 | paypal\get_payments! 69 | 70 | -- auth request, request for api call 71 | assert.same 2, #http_requests 72 | assert_oauth_token_request http_requests[1] 73 | 74 | assert_api_requrest http_requests[2], { 75 | method: "GET" 76 | url: "https://api.paypal.com/v1/payments/payment" 77 | } 78 | 79 | it "makes doesn't request oauth token twice", -> 80 | paypal\get_payments! 81 | paypal\get_payments! 82 | 83 | assert.same 3, #http_requests 84 | assert_oauth_token_request http_requests[1] 85 | 86 | for i=2,3 87 | assert_api_requrest http_requests[i], { 88 | method: "GET" 89 | url: "https://api.paypal.com/v1/payments/payment" 90 | } 91 | 92 | it "fetches new oauth token when expired", -> 93 | paypal\get_payments! 94 | paypal.last_token_time -= 200 95 | paypal\get_payments! 96 | 97 | assert.same 4, #http_requests 98 | assert_oauth_token_request http_requests[1] 99 | assert_oauth_token_request http_requests[3] 100 | 101 | describe "api calls", -> 102 | -- stub oauth request 103 | before_each -> 104 | paypal.last_token = { 105 | access_token: "ACCESS_TOKEN" 106 | expires_in: 5000 107 | } 108 | paypal.last_token_time = os.time! 109 | paypal.access_token = paypal.last_token.access_token 110 | 111 | it "sale_transaction", -> 112 | paypal\sale_transaction "TRANSACTION_ID" 113 | 114 | assert.same 1, #http_requests 115 | assert_api_requrest http_requests[1], { 116 | method: "GET" 117 | url: "https://api.paypal.com/v1/payments/sale/TRANSACTION_ID" 118 | } 119 | 120 | it "payout", -> 121 | paypal\payout { 122 | email: "leafo@example.com" 123 | amount: paypal\format_price 100, "USD" 124 | currency: "USD" 125 | } 126 | 127 | assert.same 1, #http_requests 128 | assert_api_requrest http_requests[1], { 129 | method: "POST" 130 | url: "https://api.paypal.com/v1/payments/payouts?sync_mode=true" 131 | body: { 132 | sender_batch_header: { 133 | email_subject: "You got a payout" 134 | } 135 | items: { 136 | { 137 | amount: { 138 | currency: "USD", 139 | value: "1.00" 140 | } 141 | receiver: "leafo@example.com", 142 | recipient_type: "EMAIL", 143 | note: "Payout" 144 | } 145 | } 146 | } 147 | } 148 | 149 | 150 | it "create_checkout_order", -> 151 | paypal\create_checkout_order { 152 | intent: "sale" 153 | payer: { 154 | payment_method: "paypal" 155 | } 156 | 157 | application_context: { 158 | shipping_preference: "NO_SHIPPING" 159 | } 160 | 161 | transactions: { 162 | { 163 | amount: { 164 | total: "$100.0" 165 | currency: "USD" 166 | } 167 | 168 | description: "Ghost shoes" 169 | invoice_number: "200" 170 | } 171 | } 172 | } 173 | 174 | assert.same 1, #http_requests 175 | 176 | assert_api_requrest http_requests[1], { 177 | method: "POST" 178 | url: "https://api.paypal.com/v1/checkout/orders", 179 | body: { 180 | intent: "sale" 181 | payer: { 182 | payment_method: "paypal" 183 | } 184 | 185 | application_context: { 186 | shipping_preference: "NO_SHIPPING" 187 | } 188 | 189 | transactions: { 190 | { 191 | amount: { 192 | total: "$100.0" 193 | currency: "USD" 194 | } 195 | 196 | description: "Ghost shoes" 197 | invoice_number: "200" 198 | } 199 | } 200 | } 201 | } 202 | 203 | it "create_checkout_order with different api version", -> 204 | paypal.api_version = "v2" 205 | paypal\create_checkout_order { 206 | "intent": "CAPTURE", 207 | "purchase_units": { 208 | { 209 | "amount": { 210 | "currency_code": "USD", 211 | "value": "100.00" 212 | } 213 | } 214 | } 215 | } 216 | 217 | assert.same 1, #http_requests 218 | 219 | assert_api_requrest http_requests[1], { 220 | method: "POST" 221 | url: "https://api.paypal.com/v2/checkout/orders", 222 | body: { 223 | "intent": "CAPTURE", 224 | "purchase_units": { 225 | { 226 | "amount": { 227 | "currency_code": "USD", 228 | "value": "100.00" 229 | } 230 | } 231 | } 232 | } 233 | } 234 | 235 | -------------------------------------------------------------------------------- /spec/stripe_spec.moon: -------------------------------------------------------------------------------- 1 | import types from require "tableshape" 2 | import make_http, assert_shape, url_shape, query_string_shape from require "spec.helpers" 3 | 4 | import parse_query_string, to_json from require "lapis.util" 5 | 6 | describe "stripe", -> 7 | it "creates a stripe object", -> 8 | import Stripe from require "payments.stripe" 9 | stripe = assert Stripe { 10 | client_id: "client_id" 11 | client_secret: "client_secret" 12 | publishable_key: "publishable_key" 13 | } 14 | 15 | describe "with client", -> 16 | local stripe, http_requests, http_fn 17 | local api_response 18 | 19 | -- create a test case where whatver is called in fn creates a single http 20 | -- request that matches what is described in opts 21 | api_request = (opts={}, fn) -> 22 | method = opts.method or "GET" 23 | spec_name = opts.name or "#{method} #{opts.path}" 24 | 25 | it spec_name, -> 26 | response = { fn! } 27 | 28 | assert.same { 29 | opts.response_object or {hello: "world"} 30 | 200 31 | }, response 32 | 33 | req = assert http_requests[#http_requests], "expected http request" 34 | 35 | headers = { 36 | "Host": "api.stripe.com" 37 | "Content-Type": "application/x-www-form-urlencoded" 38 | "Content-length": opts.body and types.pattern "%d+" 39 | "Authorization": "Basic Y2xpZW50X3NlY3JldDo=" 40 | } 41 | 42 | if opts.headers 43 | for k,v in pairs opts.headers 44 | headers[k] = v 45 | 46 | assert_shape req, types.shape { 47 | :method 48 | url: "https://api.stripe.com/v1#{assert opts.path, "missing path"}" 49 | 50 | sink: types.function 51 | source: opts.body and types.function 52 | 53 | headers: types.shape headers 54 | } 55 | 56 | if opts.body 57 | source = req.source! 58 | source_data = parse_query_string source 59 | expected = {k,v for k,v in pairs source_data when type(k) == "string"} 60 | assert.same opts.body, expected 61 | 62 | before_each -> 63 | api_response = nil -- reset to default 64 | import Stripe from require "payments.stripe" 65 | http_fn, http_requests = make_http (req) -> 66 | ltn12 = require "ltn12" 67 | 68 | -- if api_response is a function then it call it once per http request 69 | -- using pump.step. Use a coroutine.wrap to output multiple responses 70 | switch type(api_response) 71 | when "function" 72 | ltn12.pump.step api_response, req.sink 73 | else 74 | req.sink api_response or '{"hello": "world"}' 75 | 76 | stripe = assert Stripe { 77 | client_id: "client_id" 78 | client_secret: "client_secret" 79 | } 80 | stripe.http = http_fn 81 | 82 | describe "with account", -> 83 | api_request { 84 | path: "/charges/hello_world" 85 | headers: { 86 | "Stripe-Account": "acct_one" 87 | } 88 | }, -> 89 | stripe\for_account_id("acct_one")\get_charge "hello_world" 90 | 91 | describe "disputes", -> 92 | api_request { 93 | path: "/disputes?limit=20" 94 | }, -> 95 | stripe\list_disputes { 96 | limit: 20 97 | } 98 | 99 | describe "charges", -> 100 | api_request { 101 | path: "/accounts" 102 | }, -> 103 | stripe\list_accounts! 104 | 105 | api_request { 106 | path: "/charges/cr_cool" 107 | }, -> 108 | stripe\get_charge "cr_cool" 109 | 110 | -- creates basic charge 111 | api_request { 112 | method: "POST" 113 | path: "/charges" 114 | body: { 115 | card: "x_card" 116 | amount: "500" 117 | currency: "USD" 118 | application_fee: "200" 119 | description: "test charge" 120 | "metadata[purchase_id]": "hello world" 121 | } 122 | }, -> 123 | stripe\create_charge { 124 | card: "x_card" 125 | amount: 500 126 | currency: "USD" 127 | application_fee: 200 128 | description: "test charge" 129 | "metadata[purchase_id]": "hello world" 130 | } 131 | 132 | -- creates charge with custom access token 133 | api_request { 134 | method: "POST" 135 | path: "/charges" 136 | headers: { 137 | Authorization: "Basic eF9zZWxsZXJfdG9rOg==" 138 | } 139 | body: { 140 | card: "x_card" 141 | amount: "500" 142 | currency: "USD" 143 | application_fee: "200" 144 | description: "test charge" 145 | } 146 | }, -> 147 | stripe\create_charge { 148 | card: "x_card" 149 | amount: 500 150 | currency: "USD" 151 | application_fee: 200 152 | description: "test charge" 153 | }, "x_seller_tok" 154 | 155 | -- creates charge with deprecated charge method 156 | -- note: application_fee is passed as fee 157 | -- note: access_token can be passed in to the opts, instead of argument 158 | api_request { 159 | method: "POST" 160 | path: "/charges" 161 | headers: { 162 | Authorization: "Basic eF9zZWxsZXJfdG9rOg==" 163 | } 164 | body: { 165 | card: "x_card" 166 | amount: "500" 167 | currency: "USD" 168 | application_fee: "200" 169 | description: "test charge" 170 | } 171 | }, -> 172 | stripe\charge { 173 | access_token: "x_seller_tok" 174 | card: "x_card" 175 | amount: 500 176 | currency: "USD" 177 | fee: 200 178 | description: "test charge" 179 | } 180 | 181 | describe "each_charge", -> 182 | it "iterates through empty result", -> 183 | api_response = coroutine.wrap -> 184 | coroutine.yield to_json { 185 | has_more: false 186 | data: {} 187 | } 188 | 189 | count = 0 190 | for charge in stripe\each_charge! 191 | count += 1 192 | 193 | assert.same 0, count 194 | 195 | assert_shape http_requests, types.shape { 196 | types.partial { 197 | method: "GET" 198 | url: url_shape { 199 | scheme: "https" 200 | query: query_string_shape { 201 | limit: "50" 202 | } 203 | } 204 | } 205 | } 206 | 207 | it "iterates through result with one page with initial params", -> 208 | api_response = coroutine.wrap -> 209 | coroutine.yield to_json { 210 | has_more: false 211 | data: { 212 | { id: "ch_one" } 213 | { id: "ch_two" } 214 | } 215 | } 216 | 217 | results = [charge for charge in stripe\each_charge limit: 5] 218 | 219 | assert.same { 220 | { id: "ch_one" } 221 | { id: "ch_two" } 222 | }, results 223 | 224 | assert_shape http_requests, types.shape { 225 | types.partial { 226 | method: "GET" 227 | url: url_shape { 228 | scheme: "https" 229 | query: query_string_shape { 230 | limit: "5" 231 | } 232 | } 233 | } 234 | } 235 | 236 | it "iterates through multiple pages", -> 237 | api_response = coroutine.wrap -> 238 | coroutine.yield to_json { 239 | has_more: true 240 | data: { 241 | { id: "ch_one" } 242 | { id: "ch_two" } 243 | } 244 | } 245 | 246 | coroutine.yield to_json { 247 | has_more: false 248 | data: { 249 | { id: "ch_three" } 250 | { id: "ch_four" } 251 | } 252 | } 253 | 254 | results = [charge for charge in stripe\each_charge limit: 100] 255 | assert.same { 256 | { id: "ch_one" } 257 | { id: "ch_two" } 258 | { id: "ch_three" } 259 | { id: "ch_four" } 260 | }, results 261 | 262 | assert_shape http_requests, types.shape { 263 | types.partial { 264 | method: "GET" 265 | url: url_shape { 266 | scheme: "https" 267 | query: query_string_shape { 268 | limit: "100" 269 | } 270 | } 271 | } 272 | 273 | types.partial { 274 | method: "GET" 275 | url: url_shape { 276 | scheme: "https" 277 | query: query_string_shape { 278 | limit: "100" 279 | starting_after: "ch_two" 280 | } 281 | } 282 | } 283 | 284 | } 285 | 286 | it "iterates through multiple pages with initial starting_after", -> 287 | api_response = coroutine.wrap -> 288 | coroutine.yield to_json { 289 | has_more: true 290 | data: { 291 | { id: "ch_one" } 292 | { id: "ch_two" } 293 | } 294 | } 295 | 296 | coroutine.yield to_json { 297 | has_more: false 298 | data: { 299 | { id: "ch_three" } 300 | { id: "ch_four" } 301 | } 302 | } 303 | 304 | results = [charge for charge in stripe\each_charge starting_after: "ch_zero"] 305 | assert.same { 306 | { id: "ch_one" } 307 | { id: "ch_two" } 308 | { id: "ch_three" } 309 | { id: "ch_four" } 310 | }, results 311 | 312 | assert_shape http_requests, types.shape { 313 | types.partial { 314 | method: "GET" 315 | url: url_shape { 316 | scheme: "https" 317 | query: query_string_shape { 318 | limit: "50" 319 | starting_after: "ch_zero" 320 | } 321 | } 322 | } 323 | 324 | types.partial { 325 | method: "GET" 326 | url: url_shape { 327 | scheme: "https" 328 | query: query_string_shape { 329 | limit: "50" 330 | starting_after: "ch_two" 331 | } 332 | } 333 | } 334 | 335 | } 336 | 337 | 338 | describe "accounts", -> 339 | api_request { 340 | path: "/accounts" 341 | }, -> 342 | stripe\list_accounts! 343 | 344 | api_request { 345 | path: "/accounts/act_leafo" 346 | }, -> 347 | stripe\get_account "act_leafo" 348 | 349 | api_request { 350 | method: "POST" 351 | path: "/accounts/act_leafo" 352 | body: { 353 | name: "boot zone" 354 | } 355 | }, -> 356 | stripe\update_account "act_leafo", { 357 | name: "boot zone" 358 | } 359 | 360 | api_request { 361 | method: "DELETE" 362 | path: "/accounts/act_cool" 363 | }, -> 364 | stripe\delete_account "act_cool" 365 | 366 | api_request { 367 | method: "POST" 368 | path: "/accounts" 369 | body: { 370 | email: "leafo@itch.zone" 371 | country: "ARCTIC" 372 | managed: "true" 373 | } 374 | }, -> 375 | stripe\create_account { 376 | email: "leafo@itch.zone" 377 | country: "ARCTIC" 378 | } 379 | 380 | describe "balance_transactions", -> 381 | api_request { 382 | method: "GET" 383 | path: "/balance/history/txn_hello" 384 | }, -> 385 | stripe\get_balance_transaction "txn_hello" 386 | 387 | api_request { 388 | method: "GET" 389 | path: "/balance/history" 390 | }, -> 391 | stripe\list_balance_transactions! 392 | 393 | 394 | 395 | --------------------------------------------------------------------------------