├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── fossa.yml ├── .gitignore ├── .prettierignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── api ├── api.go ├── api_test.go ├── auth.go ├── coupons.go ├── coupons_test.go ├── db_logger.go ├── download.go ├── download_test.go ├── errors.go ├── health.go ├── helpers.go ├── instance.go ├── instance_test.go ├── log.go ├── middleware.go ├── middleware_test.go ├── order.go ├── order_test.go ├── pagination.go ├── params.go ├── payments.go ├── payments_test.go ├── reports.go ├── reports_test.go ├── router.go ├── settings.go ├── test.env ├── user.go ├── user_test.go ├── utils_test.go └── vatnumbers.go ├── app.json ├── assetstores ├── netlify.go ├── noop.go └── store.go ├── calculator ├── calculator.go ├── calculator_test.go ├── discount_type.go └── test │ └── settings_fixture.json ├── claims ├── claims.go ├── claims_test.go └── test │ └── jwt_payload_fixture.json ├── cmd ├── migrate_cmd.go ├── multi_cmd.go ├── root_cmd.go ├── serve_cmd.go └── version.go ├── conf ├── configuration.go └── logging.go ├── context └── context.go ├── coupons ├── coupons.go └── coupons_test.go ├── docker-compose.yml ├── example.env ├── go.mod ├── go.sum ├── mailer ├── mailer.go ├── mailer_test.go └── noop.go ├── main.go ├── models ├── address.go ├── connection.go ├── connection_logger.go ├── coupon.go ├── download.go ├── errors.go ├── event.go ├── helpers.go ├── hook.go ├── instance.go ├── invoice_number.go ├── line_item.go ├── order.go ├── order_notes.go ├── transaction.go └── user.go ├── netlify.toml ├── payments ├── payments.go ├── paypal │ └── paypal.go └── stripe │ └── stripe.go └── www └── index.html /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @netlify/serverless 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 21 | 22 | **- Do you want to request a *feature* or report a *bug*?** 23 | 24 | **- What is the current behavior?** 25 | 26 | **- If the current behavior is a bug, please provide the steps to reproduce.** 27 | 28 | **- What is the expected behavior?** 29 | 30 | **- Please mention your Go version, and operating system version.** 31 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | **- Summary** 15 | 16 | 20 | 21 | **- Test plan** 22 | 23 | 27 | 28 | **- Description for the changelog** 29 | 30 | 34 | 35 | **- A picture of a cute animal (not mandatory but encouraged)** 36 | -------------------------------------------------------------------------------- /.github/workflows/fossa.yml: -------------------------------------------------------------------------------- 1 | name: Dependency License Scanning 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - chore/fossa-workflow 8 | 9 | defaults: 10 | run: 11 | shell: bash 12 | 13 | jobs: 14 | fossa: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v2 19 | - name: Download fossa cli 20 | run: |- 21 | mkdir -p $HOME/.local/bin 22 | curl https://raw.githubusercontent.com/fossas/fossa-cli/master/install.sh | bash -s -- -b $HOME/.local/bin 23 | echo "$HOME/.local/bin" >> $GITHUB_PATH 24 | 25 | - name: Fossa init 26 | run: fossa init 27 | - name: Set env 28 | run: echo "line_number=$(grep -n "project" .fossa.yml | cut -f1 -d:)" >> $GITHUB_ENV 29 | - name: Configuration 30 | run: |- 31 | sed -i "${line_number}s|.*| project: git@github.com:${GITHUB_REPOSITORY}.git|" .fossa.yml 32 | cat .fossa.yml 33 | - name: Upload dependencies 34 | run: fossa analyze --debug 35 | env: 36 | FOSSA_API_KEY: ${{ secrets.FOSSA_API_KEY }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.json 2 | *.db 3 | gocommerce 4 | .env 5 | 6 | vendor/ 7 | .idea 8 | .vscode 9 | *.iml 10 | 11 | .DS_Store 12 | www/dist/ 13 | www/.DS_Store 14 | www/node_modules 15 | npm-debug.log 16 | builds/ 17 | releases/ 18 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | README.md 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: go 3 | 4 | go: 5 | - "1.13" 6 | 7 | env: 8 | global: 9 | - GO111MODULE=on 10 | 11 | install: make deps 12 | script: make all 13 | notifications: 14 | email: false 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at david@netlify.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | Contributions are always welcome, no matter how large or small. Before contributing, 4 | please read the [code of conduct](CODE_OF_CONDUCT.md). 5 | 6 | ## Setup 7 | 8 | ```sh 9 | $ git clone https://github.com/netlify/gocommerce 10 | $ cd gocommerce 11 | $ make deps 12 | ``` 13 | 14 | ## Building 15 | 16 | ```sh 17 | $ make build 18 | ``` 19 | 20 | ## Testing 21 | 22 | ```sh 23 | $ make test 24 | ``` 25 | 26 | ## Pull Requests 27 | 28 | We actively welcome your pull requests. 29 | 30 | 1. Fork the repo and create your branch from `master`. 31 | 2. If you've added code that should be tested, add tests. 32 | 3. If you've changed APIs, update the documentation. 33 | 4. Ensure the test suite passes. 34 | 5. Make sure your code lints. 35 | 36 | ## License 37 | 38 | By contributing to Netlify CMS, you agree that your contributions will be licensed 39 | under its [MIT license](LICENSE). 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.17 2 | 3 | RUN useradd -m netlify 4 | 5 | ADD . /src 6 | RUN cd /src && make deps build_linux && mv gocommerce /usr/local/bin/ 7 | 8 | USER netlify 9 | CMD ["gocommerce"] 10 | EXPOSE 8080 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Netlify 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PONY: all build deps image lint test 2 | 3 | help: ## Show this help. 4 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {sub("\\\\n",sprintf("\n%22c"," "), $$2);printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 5 | 6 | all: lint test build ## Run the tests and build the binary. 7 | 8 | os = darwin 9 | arch = amd64 10 | 11 | build: 12 | @echo "Making gocommerce for $(os)/$(arch)" 13 | GOOS=$(os) GOARCH=$(arch) go build -ldflags "-X github.com/netlify/gocommerce/cmd.Version=`git rev-parse HEAD`" 14 | 15 | build_linux: override os=linux 16 | build_linux: build 17 | 18 | package: build 19 | tar -czf gocommerce-$(os)-$(arch).tar.gz gocommerce 20 | 21 | package_linux: override os=linux 22 | package_linux: package 23 | 24 | release: ## Upload release to GitHub releases. 25 | mkdir -p builds/darwin-${TAG} 26 | GOOS=darwin GOARCH=$(arch) go build -ldflags "-X github.com/netlify/gocommerce/cmd.Version=`git rev-parse HEAD`" -o builds/darwin-${TAG}/gocommerce 27 | mkdir -p builds/linux-${TAG} 28 | GOOS=linux GOARCH=$(arch) go build -ldflags "-X github.com/netlify/gocommerce/cmd.Version=`git rev-parse HEAD`" -o builds/linux-${TAG}/gocommerce 29 | mkdir -p builds/windows-${TAG} 30 | GOOS=windows GOARCH=$(arch) go build -ldflags "-X github.com/netlify/gocommerce/cmd.Version=`git rev-parse HEAD`" -o builds/windows-${TAG}/gocommerce.exe 31 | @rm -rf releases/${TAG} 32 | mkdir -p releases/${TAG} 33 | tar -czf releases/${TAG}/gocommerce-darwin-$(arch)-${TAG}.tar.gz -C builds/darwin-${TAG} gocommerce 34 | tar -czf releases/${TAG}/gocommerce-linux-$(arch)-${TAG}.tar.gz -C builds/linux-${TAG} gocommerce 35 | zip -j releases/${TAG}/gocommerce-windows-$(arch)-${TAG}.zip builds/windows-${TAG}/gocommerce.exe 36 | @hub release create -a releases/${TAG}/gocommerce-darwin-$(arch)-${TAG}.tar.gz -a releases/${TAG}/gocommerce-linux-$(arch)-${TAG}.tar.gz -a releases/${TAG}/gocommerce-windows-$(arch)-${TAG}.zip v${TAG} 37 | 38 | 39 | deps: ## Install dependencies. 40 | @go get -u golang.org/x/lint/golint 41 | @go mod download 42 | 43 | image: ## Build the Docker image. 44 | docker build . 45 | 46 | lint: ## Lint the code 47 | golint `go list ./... | grep -v /vendor/` 48 | 49 | test: ## Run tests. 50 | go test -v -race ./... 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GoCommerce [![Build Status](https://travis-ci.org/netlify/gocommerce.svg?branch=master)](https://travis-ci.org/netlify/gocommerce) 2 | 3 | A small go based API for static e-commerce sites. 4 | 5 | It handles orders and payments. Integrates with Stripe for payments and will support 6 | international pricing and VAT verification. 7 | 8 | GoCommerce is released under the [MIT License](LICENSE). 9 | Please make sure you understand its [implications and guarantees](https://writing.kemitchell.com/2016/09/21/MIT-License-Line-by-Line.html). 10 | 11 | ### What your static site must support 12 | 13 | Each product you want to sell from your static site must have unique URL where GoCommerce 14 | can find the meta data needed for calculating pricing and taxes in order to verify that 15 | the order is legitimate before using Stripe to charge the client. 16 | 17 | The metadata can be anywhere on the page, and goes in a script tag in this format: 18 | 19 | ```html 20 | 23 | ``` 24 | 25 | The minimum required is the Sku, title and at least one "price". Default currency is USD if nothing else specified. 26 | 27 | ### VAT, Countries and Regions 28 | 29 | GoCommerce will regularly check for a file called `https://example.com/gocommerce/settings.json` 30 | 31 | This file should have settings with rules for VAT or currency regions. 32 | 33 | This file is not required for GoCommerce to work, but will enable support for various advanced 34 | features. Currently it enables VAT calculations on a per country/product type basic. 35 | 36 | The reason we make you include the file in the static site, is that you'll need to do the same 37 | VAT calculations client side during checkout to be able to show this to the user. The 38 | [commerce-js](https://github.com/netlify/netlify-commerce-js) client library can help you with 39 | this. 40 | 41 | Here's an example settings file: 42 | 43 | ```json 44 | { 45 | "taxes": [{ 46 | "percentage": 20, 47 | "product_types": ["ebook"], 48 | "countries": ["Austria", "Bulgaria", "Estonia", "France", "Gibraltar", "Slovakia", "United Kingdom"] 49 | }, { 50 | "percentage": 7, 51 | "product_types": ["book"], 52 | "countries": ["Austria", "Belgium", "Bulgaria", "Croatia", "Cyprus", "Denmark", "Estonia"] 53 | }] 54 | } 55 | ``` 56 | 57 | Based on these rules, if an order includes a product with "type" set to "ebook" in the product metadata 58 | on the site and the users billing Address is set to "Austria", GoCommerce will verify that a 20 percentage 59 | tax has been included in that product. 60 | 61 | 62 | ## JavaScript Client Library 63 | 64 | The easiest way to use GoCommerce is with [commerce-js](https://github.com/netlify/netlify-commerce-js). 65 | 66 | **IMPORTANT:** Since Release 1.8.0 of GoCommerce at least Version 5.0.0 of the JavaScript Client is required. 67 | 68 | ## Running the GoCommerce backend 69 | 70 | GoCommerce can be deployed to any server environment that runs Go. Minimum requirement for Go is version 1.11 since GoCommerce is using Go modules. 71 | 72 | The button below provides a quick way to get started by running on Heroku: 73 | 74 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/netlify/gocommerce) 75 | 76 | ## Configuration 77 | 78 | You may configure GoCommerce using either a configuration file named `.env`, 79 | environment variables, or a combination of both. Environment variables are prefixed with `GOCOMMERCE_`, and will always have precedence over values provided via file. 80 | 81 | For local dev, the easiest way to get started is to copy the included `example.env` file to `.env` 82 | 83 | ### Top-Level 84 | 85 | ``` 86 | GOCOMMERCE_SITE_URL=https://example.netlify.com/ 87 | ``` 88 | 89 | `SITE_URL` - `string` **required** 90 | 91 | The base URL your site is located at. 92 | 93 | `OPERATOR_TOKEN` - `string` *Multi-instance mode only* 94 | 95 | The shared secret with an operator (usually Netlify) for this microservice. Used to verify requests have been proxied through the operator and 96 | the payload values can be trusted. 97 | 98 | ### API 99 | 100 | ``` 101 | GOCOMMERCE_API_HOST=localhost 102 | PORT=9999 103 | ``` 104 | 105 | `API_HOST` - `string` 106 | 107 | Hostname to listen on. 108 | 109 | `PORT` (no prefix) / `API_PORT` - `number` 110 | 111 | Port number to listen on. Defaults to `8080`. 112 | 113 | `API_ENDPOINT` - `string` *Multi-instance mode only* 114 | 115 | Controls what endpoint Netlify can access this API on. 116 | 117 | ### Database 118 | 119 | ``` 120 | GOCOMMERCE_DB_DRIVER=sqlite3 121 | DATABASE_URL=gotrue.db 122 | ``` 123 | 124 | `DB_DRIVER` - `string` **required** 125 | 126 | Chooses what dialect of database you want. Choose from `sqlite3`, `mysql`, or `postgres`. 127 | 128 | `DATABASE_URL` (no prefix) / `DB_DATABASE_URL` - `string` **required** 129 | 130 | Connection string for the database. See the [gorm examples](https://github.com/jinzhu/gorm/blob/gh-pages/documents/database.md) for more details. 131 | 132 | `DB_NAMESPACE` - `string` 133 | 134 | Adds a prefix to all table names. 135 | 136 | `DB_AUTOMIGRATE` - `bool` 137 | 138 | If enabled, creates missing tables and columns upon startup. 139 | 140 | ### Logging 141 | 142 | ``` 143 | LOG_LEVEL=debug 144 | ``` 145 | 146 | `LOG_LEVEL` - `string` 147 | 148 | Controls what log levels are output. Choose from `panic`, `fatal`, `error`, `warn`, `info`, or `debug`. Defaults to `info`. 149 | 150 | `LOG_FILE` - `string` 151 | 152 | If you wish logs to be written to a file, set `log_file` to a valid file path. 153 | 154 | ### Payment 155 | 156 | #### Stripe 157 | 158 | `PAYMENT_STRIPE_ENABLED` - `bool` 159 | 160 | Whether Stripe is enabled as a payment provider or not. 161 | 162 | `PAYMENT_STRIPE_SECRET_KEY` - `string` 163 | 164 | The Stripe [secret key](https://stripe.com/docs/api#authentication) used when authenticating with the Stripe API. 165 | 166 | #### PayPal 167 | 168 | `PAYMENT_PAYPAL_ENABLED` - `bool` 169 | 170 | Whether PayPal is enabled as a payment provider or not. 171 | 172 | `PAYMENT_PAYPAL_CLIENT_ID` - `string` 173 | `PAYMENT_PAYPAL_SECRET` - `string` 174 | 175 | The OAuth credentials PayPal issued to you. GoCommerce will use them to [obtain an access token](https://developer.paypal.com/docs/api/overview/#authentication-and-authorization). 176 | 177 | `PAYMENT_PAYPAL_ENV` - `string` 178 | 179 | The PayPal environment to use. Choose from `production` or `sandbox`. 180 | 181 | ### Downloads 182 | 183 | `DOWNLOADS_PROVIDER` - `string` 184 | 185 | The provider to use for downloads. Choose from `netlify` or ``. 186 | 187 | `DOWNLOADS_NETLIFY_TOKEN` - `string` 188 | 189 | The authentication bearer token used to access the Netlify downloads API. 190 | 191 | ### Coupons 192 | 193 | `COUPONS_URL` - `string` 194 | 195 | A URL that contains all the coupon information in JSON. 196 | 197 | `COUPONS_USER` - `string` 198 | `COUPONS_PASSWORD` - `string` 199 | 200 | HTTP Basic Authentication information to use if required to access the coupon information. 201 | 202 | ### Webhooks 203 | 204 | `WEBHOOKS_ORDER` - `string` 205 | `WEBHOOKS_PAYMENT` - `string` 206 | `WEBHOOKS_UPDATE` - `string` 207 | `WEBHOOKS_REFUND` - `string` 208 | 209 | A URL to send a webhook to when the corresponding action has been performed. 210 | 211 | `WEBHOOKS_SECRET` - `string` 212 | 213 | A secret used to sign a JWT included in the `X-Commerce-Signature` header. This can be used to verify the webhook came from GoCommerce. 214 | 215 | ### JSON Web Tokens (JWT) 216 | 217 | ``` 218 | GOCOMMERCE_JWT_SECRET=supersecretvalue 219 | ``` 220 | 221 | `JWT_SECRET` - `string` **required** 222 | 223 | The secret used to verify JWT tokens with. 224 | 225 | `JWT_ADMIN_GROUP_NAME` - `string` 226 | 227 | The name of the admin group (if enabled). Defaults to `admin`. 228 | 229 | ### E-Mail 230 | 231 | Sending email is not required, but is highly recommended. 232 | If enabled, you must provide the required values below. 233 | 234 | ``` 235 | GOCOMMERCE_SMTP_HOST=smtp.mandrillapp.com 236 | GOCOMMERCE_SMTP_PORT=587 237 | GOCOMMERCE_SMTP_USER=smtp-delivery@example.com 238 | GOCOMMERCE_SMTP_PASS=correcthorsebatterystaple 239 | GOCOMMERCE_SMTP_ADMIN_EMAIL=support@example.com 240 | GOCOMMERCE_MAILER_SUBJECTS_ORDER_CONFIRMATION="Please confirm" 241 | ``` 242 | 243 | `SMTP_ADMIN_EMAIL` - `string` **required** 244 | 245 | The `From` email address for all emails sent. Order receipts are also sent to this address. 246 | 247 | `SMTP_HOST` - `string` **required** 248 | 249 | The mail server hostname to send emails through. 250 | 251 | `SMTP_PORT` - `number` **required** 252 | 253 | The port number to connect to the mail server on. 254 | 255 | `SMTP_USER` - `string` 256 | 257 | If the mail server requires authentication, the username to use. 258 | 259 | `SMTP_PASS` - `string` 260 | 261 | If the mail server requires authentication, the password to use. 262 | 263 | `MAILER_SUBJECTS_ORDER_CONFIRMATION` - `string` 264 | 265 | Email subject to use for order confirmations. Defaults to `Order Confirmation`. 266 | 267 | `MAILER_SUBJECTS_ORDER_RECEIVED` - `string` 268 | 269 | Email subject to use for orders sent to the store admin. Defaults to `Order Received From {{ .Order.Email }}`. 270 | 271 | `MAILER_TEMPLATES_ORDER_CONFIRMATION` - `string` 272 | 273 | URL path, relative to the `SITE_URL`, of an email template to use when sending an order confirmation. 274 | `Order` and `Transaction` variables are available. 275 | 276 | Default Content (if template is unavailable): 277 | ```html 278 |

Thank you for your order!

279 | 280 | 285 | 286 |

Total amount: {{ .Order.Total }}

287 | ``` 288 | 289 | `MAILER_TEMPLATES_ORDER_RECEIVED` - `string` 290 | 291 | URL path, relative to the `SITE_URL`, of an email template to use when sending order details to the store admin. 292 | `Order` and `Transaction` variables are available. 293 | 294 | Default Content (if template is unavailable): 295 | ```html 296 |

Order Received From {{ .Order.Email }}

297 | 298 | 303 | 304 |

Total amount: {{ .Order.Total }}

305 | ``` 306 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "os/signal" 8 | "regexp" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/jinzhu/gorm" 13 | "github.com/sebest/xff" 14 | 15 | "github.com/pborman/uuid" 16 | "github.com/rs/cors" 17 | "github.com/sirupsen/logrus" 18 | 19 | "github.com/go-chi/chi" 20 | "github.com/netlify/gocommerce/conf" 21 | gcontext "github.com/netlify/gocommerce/context" 22 | ) 23 | 24 | const ( 25 | defaultVersion = "unknown version" 26 | ) 27 | 28 | var ( 29 | bearerRegexp = regexp.MustCompile(`^(?:B|b)earer (\S+$)`) 30 | ) 31 | 32 | // API is the main REST API 33 | type API struct { 34 | handler http.Handler 35 | db *gorm.DB 36 | config *conf.GlobalConfiguration 37 | httpClient *http.Client 38 | version string 39 | } 40 | 41 | // ListenAndServe starts the REST API. 42 | func (a *API) ListenAndServe(hostAndPort string) { 43 | log := logrus.WithField("component", "api") 44 | server := &http.Server{ 45 | Addr: hostAndPort, 46 | Handler: a.handler, 47 | } 48 | 49 | done := make(chan struct{}) 50 | defer close(done) 51 | go func() { 52 | waitForTermination(log, done) 53 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 54 | defer cancel() 55 | server.Shutdown(ctx) 56 | }() 57 | 58 | if err := server.ListenAndServe(); err != nil { 59 | log.WithError(err).Fatal("API server failed") 60 | } 61 | } 62 | 63 | // WaitForShutdown blocks until the system signals termination or done has a value 64 | func waitForTermination(log logrus.FieldLogger, done <-chan struct{}) { 65 | signals := make(chan os.Signal, 1) 66 | signal.Notify(signals, os.Interrupt, syscall.SIGTERM, syscall.SIGINT) 67 | select { 68 | case sig := <-signals: 69 | log.Infof("Triggering shutdown from signal %s", sig) 70 | case <-done: 71 | log.Infof("Shutting down...") 72 | } 73 | } 74 | 75 | // NewAPI instantiates a new REST API using the default version. 76 | func NewAPI(globalConfig *conf.GlobalConfiguration, log logrus.FieldLogger, db *gorm.DB) *API { 77 | return NewAPIWithVersion(context.Background(), globalConfig, log, db, defaultVersion) 78 | } 79 | 80 | // NewAPIWithVersion instantiates a new REST API. 81 | func NewAPIWithVersion(ctx context.Context, globalConfig *conf.GlobalConfiguration, log logrus.FieldLogger, db *gorm.DB, version string) *API { 82 | api := &API{ 83 | config: globalConfig, 84 | db: db, 85 | httpClient: &http.Client{}, 86 | version: version, 87 | } 88 | 89 | xffmw, _ := xff.Default() 90 | logger := newStructuredLogger(log) 91 | 92 | r := newRouter() 93 | r.UseBypass(xffmw.Handler) 94 | r.Use(withRequestID) 95 | r.Use(recoverer) 96 | 97 | r.Get("/health", api.HealthCheck) 98 | 99 | r.Route("/", func(r *router) { 100 | r.UseBypass(logger) 101 | r.Use(api.loggingDB) 102 | if globalConfig.MultiInstanceMode { 103 | r.Use(api.loadInstanceConfig) 104 | } 105 | r.Use(api.withToken) 106 | 107 | r.Route("/orders", api.orderRoutes) 108 | r.Route("/users", api.userRoutes) 109 | 110 | r.Route("/downloads", func(r *router) { 111 | r.With(authRequired).Get("/", api.DownloadList) 112 | r.Get("/{download_id}", api.DownloadURL) 113 | }) 114 | 115 | r.Route("/vatnumbers", func(r *router) { 116 | r.Get("/{vat_number}", api.VatNumberLookup) 117 | }) 118 | 119 | r.Route("/payments", func(r *router) { 120 | r.With(adminRequired).Get("/", api.PaymentList) 121 | r.Route("/{payment_id}", func(r *router) { 122 | r.With(adminRequired).Get("/", api.PaymentView) 123 | r.With(adminRequired).With(addGetBody).Post("/refund", api.PaymentRefund) 124 | r.Post("/confirm", api.PaymentConfirm) 125 | }) 126 | }) 127 | 128 | r.Route("/paypal", func(r *router) { 129 | r.With(addGetBody).Post("/", api.PreauthorizePayment) 130 | }) 131 | 132 | r.Route("/reports", func(r *router) { 133 | r.Use(adminRequired) 134 | 135 | r.Get("/sales", api.SalesReport) 136 | r.Get("/products", api.ProductsReport) 137 | }) 138 | 139 | r.Route("/coupons", func(r *router) { 140 | r.With(adminRequired).Get("/", api.CouponList) 141 | r.Get("/{coupon_code}", api.CouponView) 142 | }) 143 | 144 | r.Get("/settings", api.ViewSettings) 145 | 146 | r.With(authRequired).Post("/claim", api.ClaimOrders) 147 | }) 148 | 149 | if globalConfig.MultiInstanceMode { 150 | // Operator microservice API 151 | r.WithBypass(logger).With(api.loggingDB).With(api.verifyOperatorRequest).Get("/", api.GetAppManifest) 152 | r.Route("/instances", func(r *router) { 153 | r.UseBypass(logger) 154 | r.Use(api.loggingDB) 155 | r.Use(api.verifyOperatorRequest) 156 | 157 | r.Post("/", api.CreateInstance) 158 | r.Route("/{instance_id}", func(r *router) { 159 | r.Use(api.loadInstance) 160 | 161 | r.Get("/", api.GetInstance) 162 | r.Put("/", api.UpdateInstance) 163 | r.Delete("/", api.DeleteInstance) 164 | }) 165 | }) 166 | } 167 | 168 | corsHandler := cors.New(cors.Options{ 169 | AllowedMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE"}, 170 | AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, 171 | ExposedHeaders: []string{"Link", "X-Total-Count"}, 172 | AllowCredentials: true, 173 | }) 174 | 175 | api.handler = corsHandler.Handler(chi.ServerBaseContext(ctx, r)) 176 | return api 177 | } 178 | 179 | func (a *API) orderRoutes(r *router) { 180 | r.With(authRequired).Get("/", a.OrderList) 181 | r.Post("/", a.OrderCreate) 182 | 183 | r.Route("/{order_id}", func(r *router) { 184 | r.Use(a.withOrderID) 185 | r.Get("/", a.OrderView) 186 | r.With(adminRequired).Put("/", a.OrderUpdate) 187 | 188 | r.Route("/payments", func(r *router) { 189 | r.With(authRequired).Get("/", a.PaymentListForOrder) 190 | r.With(addGetBody).Post("/", a.PaymentCreate) 191 | }) 192 | 193 | r.Route("/downloads", func(r *router) { 194 | r.Get("/", a.DownloadList) 195 | r.Post("/refresh", a.DownloadRefresh) 196 | }) 197 | r.Get("/receipt", a.ReceiptView) 198 | r.Post("/receipt", a.ResendOrderReceipt) 199 | }) 200 | } 201 | 202 | func (a *API) userRoutes(r *router) { 203 | r.Use(authRequired) 204 | r.With(adminRequired).Get("/", a.UserList) 205 | r.With(adminRequired).Delete("/", a.UserBulkDelete) 206 | 207 | r.Route("/{user_id}", func(r *router) { 208 | r.Use(a.withUser) 209 | r.Use(ensureUserAccess) 210 | 211 | r.Get("/", a.UserView) 212 | r.With(adminRequired).Delete("/", a.UserDelete) 213 | 214 | r.Get("/payments", a.PaymentListForUser) 215 | r.Get("/orders", a.OrderList) 216 | 217 | r.Route("/addresses", func(r *router) { 218 | r.Get("/", a.AddressList) 219 | r.With(adminRequired).Post("/", a.CreateNewAddress) 220 | r.Route("/{addr_id}", func(r *router) { 221 | r.Get("/", a.AddressView) 222 | r.With(adminRequired).Delete("/", a.AddressDelete) 223 | }) 224 | }) 225 | }) 226 | } 227 | 228 | func withRequestID(w http.ResponseWriter, r *http.Request) (context.Context, error) { 229 | id := uuid.NewRandom().String() 230 | ctx := r.Context() 231 | ctx = gcontext.WithRequestID(ctx, id) 232 | return ctx, nil 233 | } 234 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/sirupsen/logrus" 10 | "github.com/sirupsen/logrus/hooks/test" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/netlify/gocommerce/conf" 15 | ) 16 | 17 | func TestTraceWrapper(t *testing.T) { 18 | hook := test.NewGlobal() 19 | globalConfig := new(conf.GlobalConfiguration) 20 | globalConfig.MultiInstanceMode = true 21 | globalConfig.OperatorToken = "token" 22 | 23 | config := new(conf.Configuration) 24 | config.Payment.Stripe.Enabled = true 25 | config.Payment.Stripe.SecretKey = "secret" 26 | 27 | ctx, err := WithInstanceConfig(context.Background(), globalConfig.SMTP, config, "") 28 | require.NoError(t, err) 29 | api := NewAPIWithVersion(ctx, globalConfig, logrus.StandardLogger(), nil, "") 30 | 31 | server := httptest.NewServer(api.handler) 32 | defer server.Close() 33 | 34 | client := http.Client{} 35 | req, err := http.NewRequest(http.MethodGet, server.URL+"/", nil) 36 | require.NoError(t, err) 37 | req.Header.Add("Authorization", "Bearer token") 38 | rsp, err := client.Do(req) 39 | require.NoError(t, err) 40 | assert.Equal(t, http.StatusOK, rsp.StatusCode) 41 | assert.True(t, len(hook.Entries) > 0) 42 | 43 | for _, entry := range hook.Entries { 44 | if _, ok := entry.Data["request_id"]; !ok { 45 | assert.Fail(t, "expected entry: request_id") 46 | } 47 | expected := map[string]string{ 48 | "method": "GET", 49 | "path": "/", 50 | } 51 | for k, v := range expected { 52 | if value, ok := entry.Data[k]; ok { 53 | assert.Equal(t, v, value) 54 | } else { 55 | assert.Fail(t, "expected entry: "+k) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | jwt "github.com/golang-jwt/jwt/v4" 8 | "github.com/netlify/gocommerce/claims" 9 | gcontext "github.com/netlify/gocommerce/context" 10 | "github.com/netlify/gocommerce/models" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | func extractBearerToken(r *http.Request) (string, error) { 15 | authHeader := r.Header.Get("Authorization") 16 | if authHeader == "" { 17 | return "", nil 18 | } 19 | 20 | matches := bearerRegexp.FindStringSubmatch(authHeader) 21 | if len(matches) != 2 { 22 | return "", unauthorizedError("Bad authentication header").WithInternalMessage("Invalid auth header format: %s", authHeader) 23 | } 24 | 25 | return matches[1], nil 26 | } 27 | 28 | func (a *API) withToken(w http.ResponseWriter, r *http.Request) (context.Context, error) { 29 | ctx := r.Context() 30 | log := getLogEntry(r) 31 | config := gcontext.GetConfig(ctx) 32 | bearerToken, err := extractBearerToken(r) 33 | if err != nil { 34 | return nil, err 35 | } 36 | if bearerToken == "" { 37 | log.Info("Making unauthenticated request") 38 | return ctx, nil 39 | } 40 | 41 | if bearerToken == a.config.OperatorToken { 42 | log.Info("Making operator request") 43 | return ctx, nil 44 | } 45 | 46 | claims := claims.JWTClaims{} 47 | p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} 48 | token, err := p.ParseWithClaims(bearerToken, &claims, func(token *jwt.Token) (interface{}, error) { 49 | return []byte(config.JWT.Secret), nil 50 | }) 51 | if err != nil { 52 | return nil, unauthorizedError("Invalid token").WithInternalError(err) 53 | } 54 | 55 | isAdmin := false 56 | roles, ok := claims.AppMetaData["roles"] 57 | if ok { 58 | roleStrings, _ := roles.([]interface{}) 59 | for _, data := range roleStrings { 60 | role, _ := data.(string) 61 | if role == config.JWT.AdminGroupName { 62 | isAdmin = true 63 | break 64 | } 65 | } 66 | } 67 | 68 | log.WithFields(logrus.Fields{ 69 | "claims_sub": claims.Subject, 70 | "claims_email": claims.Email, 71 | "roles": roles, 72 | "is_admin": isAdmin, 73 | }).Debug("successfully parsed claims") 74 | 75 | ctx = gcontext.WithAdminFlag(ctx, isAdmin) 76 | ctx = gcontext.WithToken(ctx, token) 77 | return ctx, nil 78 | } 79 | 80 | func authRequired(w http.ResponseWriter, r *http.Request) (context.Context, error) { 81 | ctx := r.Context() 82 | claims := gcontext.GetClaims(ctx) 83 | if claims == nil { 84 | return nil, unauthorizedError("No claims provided") 85 | } 86 | 87 | return ctx, nil 88 | } 89 | 90 | func adminRequired(w http.ResponseWriter, r *http.Request) (context.Context, error) { 91 | ctx := r.Context() 92 | claims := gcontext.GetClaims(ctx) 93 | isAdmin := gcontext.IsAdmin(ctx) 94 | 95 | if claims == nil || !isAdmin { 96 | return nil, unauthorizedError("Admin permissions required") 97 | } 98 | 99 | logEntrySetField(r, "admin_id", claims.Subject) 100 | return ctx, nil 101 | } 102 | 103 | func ensureUserAccess(w http.ResponseWriter, r *http.Request) (context.Context, error) { 104 | ctx := r.Context() 105 | 106 | // ensure userID matches authenticated user OR is admin 107 | claims := gcontext.GetClaims(ctx) 108 | if gcontext.IsAdmin(ctx) { 109 | logEntrySetField(r, "admin_id", claims.Subject) 110 | return ctx, nil 111 | } 112 | 113 | userID := gcontext.GetUserID(ctx) 114 | if claims.Subject != userID { 115 | return nil, unauthorizedError("Can't access a different user unless you're an admin") 116 | } 117 | 118 | return ctx, nil 119 | } 120 | 121 | func hasOrderAccess(ctx context.Context, order *models.Order) bool { 122 | if order.UserID == "" { 123 | return true 124 | } 125 | if gcontext.IsAdmin(ctx) { 126 | return true 127 | } 128 | 129 | claims := gcontext.GetClaims(ctx) 130 | return claims != nil && order.UserID == claims.Subject 131 | } 132 | -------------------------------------------------------------------------------- /api/coupons.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "context" 7 | 8 | "github.com/go-chi/chi" 9 | gcontext "github.com/netlify/gocommerce/context" 10 | "github.com/netlify/gocommerce/coupons" 11 | "github.com/netlify/gocommerce/models" 12 | ) 13 | 14 | func (a *API) lookupCoupon(ctx context.Context, w http.ResponseWriter, code string) (*models.Coupon, error) { 15 | couponCache := gcontext.GetCoupons(ctx) 16 | if couponCache == nil { 17 | return nil, notFoundError("No coupons available") 18 | } 19 | 20 | coupon, err := couponCache.Lookup(code) 21 | if err != nil { 22 | switch v := err.(type) { 23 | case *coupons.CouponNotFound: 24 | return nil, notFoundError(v.Error()) 25 | default: 26 | return nil, internalServerError("Error fetching coupon").WithInternalError(err) 27 | } 28 | } 29 | 30 | return coupon, nil 31 | } 32 | 33 | // CouponView returns information about a single coupon code. 34 | func (a *API) CouponView(w http.ResponseWriter, r *http.Request) error { 35 | ctx := r.Context() 36 | log := getLogEntry(r) 37 | code := chi.URLParam(r, "coupon_code") 38 | coupon, err := a.lookupCoupon(ctx, w, code) 39 | if err != nil { 40 | log.WithError(err).Infof("error loading coupon %v", err) 41 | return err 42 | } 43 | 44 | return sendJSON(w, http.StatusOK, coupon) 45 | } 46 | 47 | // CouponList returns all the coupons for the site. Requires admin permissions 48 | func (a *API) CouponList(w http.ResponseWriter, r *http.Request) error { 49 | ctx := r.Context() 50 | log := getLogEntry(r) 51 | 52 | couponCache := gcontext.GetCoupons(ctx) 53 | if couponCache == nil { 54 | return sendJSON(w, http.StatusOK, []string{}) 55 | } 56 | 57 | coupons, err := couponCache.List() 58 | if err != nil { 59 | log.WithError(err).Errorf("Error loading coupons: %v", err) 60 | return internalServerError("Error fetching coupons: %v", err) 61 | } 62 | 63 | return sendJSON(w, http.StatusOK, coupons) 64 | } 65 | -------------------------------------------------------------------------------- /api/coupons_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/netlify/gocommerce/models" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestCouponView(t *testing.T) { 14 | t.Run("NotFound", func(t *testing.T) { 15 | test := NewRouteTest(t) 16 | recorder := test.TestEndpoint(http.MethodGet, "/coupons/coupon-code", nil, nil) 17 | validateError(t, http.StatusNotFound, recorder) 18 | }) 19 | t.Run("Simple", func(t *testing.T) { 20 | test := NewRouteTest(t) 21 | server := startTestCouponURLs() 22 | defer server.Close() 23 | test.Config.Coupons.URL = server.URL 24 | 25 | recorder := test.TestEndpoint(http.MethodGet, "/coupons/coupon-code", nil, nil) 26 | coupon := &models.Coupon{} 27 | extractPayload(t, http.StatusOK, recorder, coupon) 28 | assert.Equal(t, uint64(15), coupon.Percentage, "Expected coupon percetage to be 15") 29 | assert.Equal(t, "coupon-code", coupon.Code, "Expected coupon code to be 'coupon-code'") 30 | }) 31 | } 32 | 33 | func startTestCouponURLs() *httptest.Server { 34 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | w.Header().Set("Content-Type", "application/json") 36 | fmt.Fprintln(w, `{ 37 | "coupons": { 38 | "coupon-code": { 39 | "percentage": 15 40 | } 41 | } 42 | }`) 43 | })) 44 | } 45 | -------------------------------------------------------------------------------- /api/db_logger.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/jinzhu/gorm" 8 | gcontext "github.com/netlify/gocommerce/context" 9 | "github.com/netlify/gocommerce/models" 10 | ) 11 | 12 | func (a *API) loggingDB(w http.ResponseWriter, r *http.Request) (context.Context, error) { 13 | if a.db == nil { 14 | return r.Context(), nil 15 | } 16 | 17 | log := getLogEntry(r) 18 | db := a.db.New() 19 | db.SetLogger(models.NewDBLogger(log)) 20 | 21 | return gcontext.WithDB(r.Context(), db), nil 22 | } 23 | 24 | // DB provides callers with a database instance configured for request logging 25 | func (a *API) DB(r *http.Request) *gorm.DB { 26 | ctx := r.Context() 27 | return gcontext.GetDB(ctx) 28 | } 29 | -------------------------------------------------------------------------------- /api/download.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/go-chi/chi" 8 | "github.com/jinzhu/gorm" 9 | gcontext "github.com/netlify/gocommerce/context" 10 | "github.com/netlify/gocommerce/models" 11 | ) 12 | 13 | const maxIPsPerDay = 50 14 | 15 | // DownloadURL returns a signed URL to download a purchased asset. 16 | func (a *API) DownloadURL(w http.ResponseWriter, r *http.Request) error { 17 | ctx := r.Context() 18 | db := a.DB(r) 19 | downloadID := chi.URLParam(r, "download_id") 20 | logEntrySetField(r, "download_id", downloadID) 21 | claims := gcontext.GetClaims(ctx) 22 | assets := gcontext.GetAssetStore(ctx) 23 | 24 | download := &models.Download{} 25 | if result := db.Where("id = ?", downloadID).First(download); result.Error != nil { 26 | if result.RecordNotFound() { 27 | return notFoundError("Download not found") 28 | } 29 | return internalServerError("Error during database query").WithInternalError(result.Error) 30 | } 31 | 32 | order := &models.Order{} 33 | if result := db.Where("id = ?", download.OrderID).First(order); result.Error != nil { 34 | if result.RecordNotFound() { 35 | return notFoundError("Download order not found") 36 | } 37 | return internalServerError("Error during database query").WithInternalError(result.Error) 38 | } 39 | 40 | if !hasOrderAccess(ctx, order) { 41 | return unauthorizedError("Not Authorized to access this download") 42 | } 43 | 44 | if order.PaymentState != models.PaidState { 45 | return unauthorizedError("This download has not been paid yet") 46 | } 47 | 48 | rows, err := db.Model(&models.Event{}). 49 | Select("count(distinct(ip))"). 50 | Where("order_id = ? and created_at > ? and changes = 'download'", order.ID, time.Now().Add(-24*time.Hour)). 51 | Rows() 52 | if err != nil { 53 | return internalServerError("Error signing download").WithInternalError(err) 54 | } 55 | var count uint64 56 | for rows.Next() { 57 | err = rows.Scan(&count) 58 | if err != nil { 59 | return internalServerError("Error signing download").WithInternalError(err) 60 | } 61 | } 62 | if count > maxIPsPerDay { 63 | return unauthorizedError("This download has been accessed from too many IPs within the last day") 64 | } 65 | 66 | if err := download.SignURL(assets); err != nil { 67 | return internalServerError("Error signing download").WithInternalError(err) 68 | } 69 | 70 | tx := db.Begin() 71 | tx.Model(download).Updates(map[string]interface{}{"download_count": gorm.Expr("download_count + 1")}) 72 | var subject string 73 | if claims != nil { 74 | subject = claims.Subject 75 | } 76 | models.LogEvent(tx, r.RemoteAddr, subject, order.ID, models.EventUpdated, []string{"download"}) 77 | tx.Commit() 78 | 79 | return sendJSON(w, http.StatusOK, download) 80 | } 81 | 82 | // DownloadList lists all purchased downloads for an order or a user. 83 | func (a *API) DownloadList(w http.ResponseWriter, r *http.Request) error { 84 | ctx := r.Context() 85 | db := a.DB(r) 86 | orderID := gcontext.GetOrderID(ctx) 87 | log := getLogEntry(r) 88 | 89 | order := &models.Order{} 90 | if orderID != "" { 91 | if result := db.Where("id = ?", orderID).First(order); result.Error != nil { 92 | if result.RecordNotFound() { 93 | return notFoundError("Download order not found") 94 | } 95 | return internalServerError("Error during database query").WithInternalError(result.Error) 96 | } 97 | } else { 98 | order = nil 99 | } 100 | 101 | if order != nil { 102 | if !hasOrderAccess(ctx, order) { 103 | return unauthorizedError("You don't have permission to access this order") 104 | } 105 | 106 | if order.PaymentState != models.PaidState { 107 | return unauthorizedError("This order has not been completed yet") 108 | } 109 | } 110 | 111 | orderTable := db.NewScope(models.Order{}).QuotedTableName() 112 | downloadsTable := db.NewScope(models.Download{}).QuotedTableName() 113 | 114 | query := db.Joins("join " + orderTable + " ON " + downloadsTable + ".order_id = " + orderTable + ".id and " + orderTable + ".payment_state = 'paid'") 115 | if order != nil { 116 | query = query.Where(orderTable+".id = ?", order.ID) 117 | } else { 118 | claims := gcontext.GetClaims(ctx) 119 | query = query.Where(orderTable+".user_id = ?", claims.Subject) 120 | } 121 | 122 | offset, limit, err := paginate(w, r, query.Model(&models.Download{})) 123 | if err != nil { 124 | return badRequestError("Bad Pagination Parameters: %v", err) 125 | } 126 | 127 | var downloads []models.Download 128 | if result := query.Offset(offset).Limit(limit).Find(&downloads); result.Error != nil { 129 | return internalServerError("Error during database query").WithInternalError(err) 130 | } 131 | 132 | log.WithField("download_count", len(downloads)).Debugf("Successfully retrieved %d downloads", len(downloads)) 133 | return sendJSON(w, http.StatusOK, downloads) 134 | } 135 | 136 | // DownloadRefresh makes sure downloads are up to date 137 | func (a *API) DownloadRefresh(w http.ResponseWriter, r *http.Request) error { 138 | ctx := r.Context() 139 | orderID := gcontext.GetOrderID(ctx) 140 | config := gcontext.GetConfig(ctx) 141 | log := getLogEntry(r) 142 | 143 | order := &models.Order{} 144 | if orderID == "" { 145 | return badRequestError("Order id missing") 146 | } 147 | 148 | query := a.db.Where("id = ?", orderID). 149 | Preload("LineItems"). 150 | Preload("Downloads") 151 | if result := query.First(order); result.Error != nil { 152 | if result.RecordNotFound() { 153 | return notFoundError("Download order not found") 154 | } 155 | return internalServerError("Error during database query").WithInternalError(result.Error) 156 | } 157 | 158 | if !hasOrderAccess(ctx, order) { 159 | return unauthorizedError("You don't have permission to access this order") 160 | } 161 | 162 | if order.PaymentState != models.PaidState { 163 | return unauthorizedError("This order has not been completed yet") 164 | } 165 | 166 | if err := order.UpdateDownloads(config, log); err != nil { 167 | return internalServerError("Error during updating downloads").WithInternalError(err) 168 | } 169 | 170 | if result := a.db.Save(order); result.Error != nil { 171 | return internalServerError("Error during saving order").WithInternalError(result.Error) 172 | } 173 | 174 | return sendJSON(w, http.StatusOK, map[string]string{}) 175 | } 176 | -------------------------------------------------------------------------------- /api/download_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | 11 | "github.com/netlify/gocommerce/models" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestDownloadList(t *testing.T) { 16 | t.Run("UserList", func(t *testing.T) { 17 | test := NewRouteTest(t) 18 | token := test.Data.testUserToken 19 | recorder := test.TestEndpoint(http.MethodGet, "/downloads", nil, token) 20 | 21 | downloads := []models.Download{} 22 | extractPayload(t, http.StatusOK, recorder, &downloads) 23 | assert.Len(t, downloads, 1) 24 | }) 25 | } 26 | 27 | func currentDownloads(test *RouteTest) []models.Download { 28 | recorder := test.TestEndpoint(http.MethodGet, "/downloads", nil, test.Data.testUserToken) 29 | 30 | downloads := []models.Download{} 31 | extractPayload(test.T, http.StatusOK, recorder, &downloads) 32 | return downloads 33 | } 34 | 35 | type DownloadMeta struct { 36 | Title string `json:"title"` 37 | URL string `json:"url"` 38 | } 39 | 40 | func startTestSiteWithDownloads(t *testing.T, downloads []*DownloadMeta) *httptest.Server { 41 | downloadsList, err := json.Marshal(downloads) 42 | assert.NoError(t, err) 43 | return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | switch r.URL.Path { 45 | case "/i/believe/i/can/fly": 46 | fmt.Fprintf(w, productMetaFrame(` 47 | {"sku": "123-i-can-fly-456", "downloads": %s}`), 48 | string(downloadsList), 49 | ) 50 | } 51 | })) 52 | } 53 | 54 | func TestDownloadRefresh(t *testing.T) { 55 | test := NewRouteTest(t) 56 | downloadsBefore := currentDownloads(test) 57 | 58 | testSite := startTestSiteWithDownloads(t, []*DownloadMeta{ 59 | &DownloadMeta{ 60 | Title: "Updated Download", 61 | URL: "/my/special/new/url", 62 | }, 63 | }) 64 | defer testSite.Close() 65 | test.Config.SiteURL = testSite.URL 66 | 67 | url := fmt.Sprintf("/orders/%s/downloads/refresh", test.Data.firstOrder.ID) 68 | recorder := test.TestEndpoint(http.MethodPost, url, nil, test.Data.testUserToken) 69 | body, err := ioutil.ReadAll(recorder.Body) 70 | assert.NoError(t, err) 71 | assert.Equal(t, http.StatusOK, recorder.Code, "Failure: %s", string(body)) 72 | 73 | downloadsAfter := currentDownloads(test) 74 | 75 | assert.Equal(t, len(downloadsBefore)+1, len(downloadsAfter)) 76 | exists := false 77 | for _, download := range downloadsAfter { 78 | found := false 79 | for _, prev := range downloadsBefore { 80 | if download.ID == prev.ID { 81 | found = true 82 | break 83 | } 84 | } 85 | if !found { 86 | assert.Equal(t, "/my/special/new/url", download.URL) 87 | assert.Equal(t, "123-i-can-fly-456", download.Sku) 88 | exists = true 89 | } 90 | } 91 | assert.True(t, exists) 92 | } 93 | -------------------------------------------------------------------------------- /api/errors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "runtime/debug" 9 | 10 | gcontext "github.com/netlify/gocommerce/context" 11 | ) 12 | 13 | func badRequestError(fmtString string, args ...interface{}) *HTTPError { 14 | return httpError(http.StatusBadRequest, fmtString, args...) 15 | } 16 | 17 | func internalServerError(fmtString string, args ...interface{}) *HTTPError { 18 | return httpError(http.StatusInternalServerError, fmtString, args...) 19 | } 20 | 21 | func notFoundError(fmtString string, args ...interface{}) *HTTPError { 22 | return httpError(http.StatusNotFound, fmtString, args...) 23 | } 24 | 25 | func unauthorizedError(fmtString string, args ...interface{}) *HTTPError { 26 | return httpError(http.StatusUnauthorized, fmtString, args...) 27 | } 28 | 29 | // HTTPError is an error with a message and an HTTP status code. 30 | type HTTPError struct { 31 | Code int `json:"code"` 32 | Message string `json:"msg"` 33 | InternalError error `json:"-"` 34 | InternalMessage string `json:"-"` 35 | ErrorID string `json:"error_id,omitempty"` 36 | } 37 | 38 | func (e *HTTPError) Error() string { 39 | if e.InternalMessage != "" { 40 | return e.InternalMessage 41 | } 42 | return fmt.Sprintf("%d: %s", e.Code, e.Message) 43 | } 44 | 45 | // Cause returns the root cause error 46 | func (e *HTTPError) Cause() error { 47 | if e.InternalError != nil { 48 | return e.InternalError 49 | } 50 | return e 51 | } 52 | 53 | // WithInternalError adds internal error information to the error 54 | func (e *HTTPError) WithInternalError(err error) *HTTPError { 55 | e.InternalError = err 56 | return e 57 | } 58 | 59 | // WithInternalMessage adds internal message information to the error 60 | func (e *HTTPError) WithInternalMessage(fmtString string, args ...interface{}) *HTTPError { 61 | e.InternalMessage = fmt.Sprintf(fmtString, args...) 62 | return e 63 | } 64 | 65 | func httpError(code int, fmtString string, args ...interface{}) *HTTPError { 66 | return &HTTPError{ 67 | Code: code, 68 | Message: fmt.Sprintf(fmtString, args...), 69 | } 70 | } 71 | 72 | // Recoverer is a middleware that recovers from panics, logs the panic (and a 73 | // backtrace), and returns a HTTP 500 (Internal Server Error) status if 74 | // possible. Recoverer prints a request ID if one is provided. 75 | func recoverer(w http.ResponseWriter, r *http.Request) (context.Context, error) { 76 | defer func() { 77 | if rvr := recover(); rvr != nil { 78 | 79 | logEntry := getLogEntry(r) 80 | if logEntry != nil { 81 | logEntry.Panic(rvr, debug.Stack()) 82 | } else { 83 | fmt.Fprintf(os.Stderr, "Panic: %+v\n", rvr) 84 | debug.PrintStack() 85 | } 86 | 87 | se := &HTTPError{ 88 | Code: http.StatusInternalServerError, 89 | Message: http.StatusText(http.StatusInternalServerError), 90 | } 91 | handleError(se, w, r) 92 | } 93 | }() 94 | 95 | return nil, nil 96 | } 97 | 98 | func handleError(err error, w http.ResponseWriter, r *http.Request) { 99 | log := getLogEntry(r) 100 | errorID := gcontext.GetRequestID(r.Context()) 101 | switch e := err.(type) { 102 | case *HTTPError: 103 | if e.Code >= http.StatusInternalServerError { 104 | e.ErrorID = errorID 105 | // this will get us the stack trace too 106 | log.WithError(e.Cause()).Error(e.Error()) 107 | } else { 108 | log.WithError(e.Cause()).Info(e.Error()) 109 | } 110 | if jsonErr := sendJSON(w, e.Code, e); jsonErr != nil { 111 | handleError(jsonErr, w, r) 112 | } 113 | default: 114 | log.WithError(e).Errorf("Unhandled server error: %s", e.Error()) 115 | // hide real error details from response to prevent info leaks 116 | w.WriteHeader(http.StatusInternalServerError) 117 | if _, writeErr := w.Write([]byte(`{"code":500,"msg":"Internal server error","error_id":"` + errorID + `"}`)); writeErr != nil { 118 | log.WithError(writeErr).Error("Error writing generic error message") 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /api/health.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | // HealthCheck endpoint 8 | func (a *API) HealthCheck(w http.ResponseWriter, r *http.Request) error { 9 | return sendJSON(w, http.StatusOK, map[string]string{ 10 | "version": a.version, 11 | "name": "GoCommerce", 12 | "description": "GoCommerce is a flexible Ecommerce API for JAMStack sites", 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /api/helpers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | func sendJSON(w http.ResponseWriter, status int, obj interface{}) error { 12 | w.Header().Set("Content-Type", "application/json") 13 | b, err := json.Marshal(obj) 14 | if err != nil { 15 | return errors.Wrap(err, fmt.Sprintf("Error encoding json response: %v", obj)) 16 | } 17 | w.WriteHeader(status) 18 | _, err = w.Write(b) 19 | return err 20 | } 21 | -------------------------------------------------------------------------------- /api/instance.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi" 9 | "github.com/netlify/gocommerce/conf" 10 | gcontext "github.com/netlify/gocommerce/context" 11 | "github.com/netlify/gocommerce/models" 12 | "github.com/pborman/uuid" 13 | ) 14 | 15 | func (a *API) loadInstance(w http.ResponseWriter, r *http.Request) (context.Context, error) { 16 | instanceID := chi.URLParam(r, "instance_id") 17 | logEntrySetField(r, "instance_id", instanceID) 18 | 19 | i, err := models.GetInstance(a.DB(r), instanceID) 20 | if err != nil { 21 | if models.IsNotFoundError(err) { 22 | return nil, notFoundError("Instance not found") 23 | } 24 | return nil, internalServerError("Database error loading instance").WithInternalError(err) 25 | } 26 | return gcontext.WithInstance(r.Context(), i), nil 27 | } 28 | 29 | func (a *API) GetAppManifest(w http.ResponseWriter, r *http.Request) error { 30 | // TODO update to real manifest 31 | return sendJSON(w, http.StatusOK, map[string]string{ 32 | "version": a.version, 33 | "name": "GoCommerce", 34 | "description": "GoCommerce is a flexible Ecommerce API for JAMStack sites", 35 | }) 36 | } 37 | 38 | type InstanceRequestParams struct { 39 | UUID string `json:"uuid"` 40 | BaseConfig *conf.Configuration `json:"config"` 41 | } 42 | 43 | type InstanceResponse struct { 44 | models.Instance 45 | Endpoint string `json:"endpoint"` 46 | State string `json:"state"` 47 | } 48 | 49 | func (a *API) CreateInstance(w http.ResponseWriter, r *http.Request) error { 50 | db := a.DB(r) 51 | 52 | params := InstanceRequestParams{} 53 | if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { 54 | return badRequestError("Error decoding params: %v", err) 55 | } 56 | 57 | _, err := models.GetInstanceByUUID(db, params.UUID) 58 | if err != nil { 59 | if !models.IsNotFoundError(err) { 60 | return internalServerError("Database error looking up instance").WithInternalError(err) 61 | } 62 | } else { 63 | return badRequestError("An instance with that UUID already exists") 64 | } 65 | 66 | i := models.Instance{ 67 | ID: uuid.NewRandom().String(), 68 | UUID: params.UUID, 69 | BaseConfig: params.BaseConfig, 70 | } 71 | if err = models.CreateInstance(db, &i); err != nil { 72 | return internalServerError("Database error creating instance").WithInternalError(err) 73 | } 74 | 75 | resp := InstanceResponse{ 76 | Instance: i, 77 | Endpoint: a.config.API.Endpoint, 78 | State: "active", 79 | } 80 | return sendJSON(w, http.StatusCreated, resp) 81 | } 82 | 83 | func (a *API) GetInstance(w http.ResponseWriter, r *http.Request) error { 84 | i := gcontext.GetInstance(r.Context()) 85 | return sendJSON(w, http.StatusOK, i) 86 | } 87 | 88 | func (a *API) UpdateInstance(w http.ResponseWriter, r *http.Request) error { 89 | i := gcontext.GetInstance(r.Context()) 90 | 91 | params := InstanceRequestParams{} 92 | if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil { 93 | return badRequestError("Error decoding params: %v", err) 94 | } 95 | 96 | if params.BaseConfig != nil { 97 | i.BaseConfig = params.BaseConfig 98 | } 99 | 100 | if err := models.UpdateInstance(a.DB(r), i); err != nil { 101 | return internalServerError("Database error updating instance").WithInternalError(err) 102 | } 103 | return sendJSON(w, http.StatusOK, i) 104 | } 105 | 106 | func (a *API) DeleteInstance(w http.ResponseWriter, r *http.Request) error { 107 | i := gcontext.GetInstance(r.Context()) 108 | if err := models.DeleteInstance(a.DB(r), i); err != nil { 109 | return internalServerError("Database error deleting instance").WithInternalError(err) 110 | } 111 | 112 | w.WriteHeader(http.StatusNoContent) 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /api/instance_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/pborman/uuid" 11 | 12 | "github.com/netlify/gocommerce/conf" 13 | "github.com/netlify/gocommerce/models" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | "github.com/stretchr/testify/suite" 17 | ) 18 | 19 | const testUUID = "11111111-1111-1111-1111-111111111111" 20 | const operatorToken = "operatorToken" 21 | 22 | type InstanceTestSuite struct { 23 | suite.Suite 24 | API *API 25 | } 26 | 27 | func (ts *InstanceTestSuite) SetupTest() { 28 | globalConfig, log, err := conf.LoadGlobal("test.env") 29 | require.NoError(ts.T(), err) 30 | globalConfig.OperatorToken = operatorToken 31 | globalConfig.MultiInstanceMode = true 32 | db, err := models.Connect(globalConfig, log) 33 | require.NoError(ts.T(), err) 34 | 35 | api := NewAPI(globalConfig, log, db) 36 | ts.API = api 37 | 38 | // Cleanup existing instance 39 | i, err := models.GetInstanceByUUID(db, testUUID) 40 | if err == nil { 41 | require.NoError(ts.T(), models.DeleteInstance(db, i)) 42 | } 43 | } 44 | 45 | func (ts *InstanceTestSuite) TestCreate() { 46 | // Request body 47 | var buffer bytes.Buffer 48 | require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ 49 | "uuid": testUUID, 50 | "site_url": "https://example.netlify.com", 51 | "config": map[string]interface{}{ 52 | "jwt": map[string]interface{}{ 53 | "secret": "testsecret", 54 | }, 55 | }, 56 | })) 57 | 58 | // Setup request 59 | req := httptest.NewRequest(http.MethodPost, "http://localhost/instances", &buffer) 60 | req.Header.Set("Content-Type", "application/json") 61 | req.Header.Set("Authorization", "Bearer "+operatorToken) 62 | 63 | // Setup response recorder 64 | w := httptest.NewRecorder() 65 | 66 | ts.API.handler.ServeHTTP(w, req) 67 | require.Equal(ts.T(), http.StatusCreated, w.Code) 68 | resp := models.Instance{} 69 | require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&resp)) 70 | assert.NotNil(ts.T(), resp.BaseConfig) 71 | 72 | i, err := models.GetInstanceByUUID(ts.API.db, testUUID) 73 | require.NoError(ts.T(), err) 74 | assert.NotNil(ts.T(), i.BaseConfig) 75 | } 76 | 77 | func (ts *InstanceTestSuite) TestGet() { 78 | instanceID := uuid.NewRandom().String() 79 | err := models.CreateInstance(ts.API.db, &models.Instance{ 80 | ID: instanceID, 81 | UUID: testUUID, 82 | BaseConfig: &conf.Configuration{ 83 | JWT: conf.JWTConfiguration{ 84 | Secret: "testsecret", 85 | }, 86 | }, 87 | }) 88 | require.NoError(ts.T(), err) 89 | 90 | req := httptest.NewRequest(http.MethodGet, "http://localhost/instances/"+instanceID, nil) 91 | req.Header.Set("Content-Type", "application/json") 92 | req.Header.Set("Authorization", "Bearer "+operatorToken) 93 | 94 | w := httptest.NewRecorder() 95 | ts.API.handler.ServeHTTP(w, req) 96 | require.Equal(ts.T(), http.StatusOK, w.Code) 97 | resp := models.Instance{} 98 | require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&resp)) 99 | 100 | assert.Equal(ts.T(), "testsecret", resp.BaseConfig.JWT.Secret) 101 | } 102 | 103 | func (ts *InstanceTestSuite) TestUpdate() { 104 | instanceID := uuid.NewRandom().String() 105 | err := models.CreateInstance(ts.API.db, &models.Instance{ 106 | ID: instanceID, 107 | UUID: testUUID, 108 | BaseConfig: &conf.Configuration{ 109 | JWT: conf.JWTConfiguration{ 110 | Secret: "testsecret", 111 | }, 112 | }, 113 | }) 114 | require.NoError(ts.T(), err) 115 | 116 | var buffer bytes.Buffer 117 | require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{ 118 | "config": &conf.Configuration{ 119 | JWT: conf.JWTConfiguration{ 120 | Secret: "testsecret", 121 | }, 122 | SiteURL: "https://test.mysite.com", 123 | }, 124 | })) 125 | 126 | req := httptest.NewRequest(http.MethodPut, "http://localhost/instances/"+instanceID, &buffer) 127 | req.Header.Set("Content-Type", "application/json") 128 | req.Header.Set("Authorization", "Bearer "+operatorToken) 129 | 130 | w := httptest.NewRecorder() 131 | ts.API.handler.ServeHTTP(w, req) 132 | require.Equal(ts.T(), http.StatusOK, w.Code) 133 | 134 | i, err := models.GetInstanceByUUID(ts.API.db, testUUID) 135 | require.NoError(ts.T(), err) 136 | require.Equal(ts.T(), "testsecret", i.BaseConfig.JWT.Secret) 137 | require.Equal(ts.T(), "https://test.mysite.com", i.BaseConfig.SiteURL) 138 | } 139 | 140 | func TestInstance(t *testing.T) { 141 | suite.Run(t, new(InstanceTestSuite)) 142 | } 143 | -------------------------------------------------------------------------------- /api/log.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "time" 7 | 8 | chimiddleware "github.com/go-chi/chi/middleware" 9 | gcontext "github.com/netlify/gocommerce/context" 10 | "github.com/sirupsen/logrus" 11 | ) 12 | 13 | func newStructuredLogger(logger logrus.FieldLogger) func(next http.Handler) http.Handler { 14 | return chimiddleware.RequestLogger(&structuredLogger{logger}) 15 | } 16 | 17 | type structuredLogger struct { 18 | Logger logrus.FieldLogger 19 | } 20 | 21 | func (l *structuredLogger) NewLogEntry(r *http.Request) chimiddleware.LogEntry { 22 | entry := &structuredLoggerEntry{Logger: l.Logger} 23 | logFields := logrus.Fields{ 24 | "component": "api", 25 | "method": r.Method, 26 | "path": r.URL.Path, 27 | "remote_addr": r.RemoteAddr, 28 | "referer": r.Referer(), 29 | } 30 | 31 | if reqID := gcontext.GetRequestID(r.Context()); reqID != "" { 32 | logFields["request_id"] = reqID 33 | } 34 | 35 | entry.Logger = entry.Logger.WithFields(logFields) 36 | entry.Logger.Infoln("request started") 37 | return entry 38 | } 39 | 40 | type structuredLoggerEntry struct { 41 | Logger logrus.FieldLogger 42 | } 43 | 44 | func (l *structuredLoggerEntry) Write(status, bytes int, elapsed time.Duration) { 45 | l.Logger = l.Logger.WithFields(logrus.Fields{ 46 | "status": status, 47 | "duration": elapsed.Nanoseconds(), 48 | }) 49 | 50 | l.Logger.Info("Completed request") 51 | } 52 | 53 | func (l *structuredLoggerEntry) Panic(v interface{}, stack []byte) { 54 | l.Logger.WithFields(logrus.Fields{ 55 | "stack": string(stack), 56 | "panic": fmt.Sprintf("%+v", v), 57 | }).Panic("unhandled request panic") 58 | } 59 | 60 | func getLogEntry(r *http.Request) logrus.FieldLogger { 61 | entry, _ := chimiddleware.GetLogEntry(r).(*structuredLoggerEntry) 62 | if entry == nil { 63 | return logrus.NewEntry(logrus.StandardLogger()) 64 | } 65 | return entry.Logger 66 | } 67 | 68 | func logEntrySetField(r *http.Request, key string, value interface{}) logrus.FieldLogger { 69 | if entry, ok := r.Context().Value(chimiddleware.LogEntryCtxKey).(*structuredLoggerEntry); ok { 70 | entry.Logger = entry.Logger.WithField(key, value) 71 | return entry.Logger 72 | } 73 | return nil 74 | } 75 | 76 | func logEntrySetFields(r *http.Request, fields logrus.Fields) logrus.FieldLogger { 77 | if entry, ok := r.Context().Value(chimiddleware.LogEntryCtxKey).(*structuredLoggerEntry); ok { 78 | entry.Logger = entry.Logger.WithFields(fields) 79 | return entry.Logger 80 | } 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /api/middleware.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | jwt "github.com/golang-jwt/jwt/v4" 11 | "github.com/netlify/gocommerce/assetstores" 12 | "github.com/netlify/gocommerce/conf" 13 | gcontext "github.com/netlify/gocommerce/context" 14 | "github.com/netlify/gocommerce/mailer" 15 | "github.com/netlify/gocommerce/models" 16 | "github.com/pkg/errors" 17 | ) 18 | 19 | const ( 20 | jwsSignatureHeaderName = "x-nf-sign" 21 | ) 22 | 23 | type NetlifyMicroserviceClaims struct { 24 | SiteURL string `json:"site_url"` 25 | InstanceID string `json:"id"` 26 | NetlifyID string `json:"netlify_id"` 27 | jwt.StandardClaims 28 | } 29 | 30 | func addGetBody(w http.ResponseWriter, req *http.Request) (context.Context, error) { 31 | if req.Body == nil || req.Body == http.NoBody { 32 | return nil, badRequestError("request must provide a body") 33 | } 34 | 35 | buf, err := ioutil.ReadAll(req.Body) 36 | if err != nil { 37 | return nil, internalServerError("Error reading body").WithInternalError(err) 38 | } 39 | req.GetBody = func() (io.ReadCloser, error) { 40 | return ioutil.NopCloser(bytes.NewReader(buf)), nil 41 | } 42 | req.Body, _ = req.GetBody() 43 | return req.Context(), nil 44 | } 45 | 46 | func (api *API) verifyOperatorRequest(w http.ResponseWriter, req *http.Request) (context.Context, error) { 47 | c, _, err := api.extractOperatorRequest(w, req) 48 | return c, err 49 | } 50 | 51 | func (api *API) extractOperatorRequest(w http.ResponseWriter, req *http.Request) (context.Context, string, error) { 52 | token, err := extractBearerToken(req) 53 | if err != nil { 54 | return nil, token, err 55 | } 56 | if token == "" || token != api.config.OperatorToken { 57 | return nil, token, unauthorizedError("Request does not include an Operator token") 58 | } 59 | return req.Context(), token, nil 60 | } 61 | 62 | func (api *API) loadInstanceConfig(w http.ResponseWriter, r *http.Request) (context.Context, error) { 63 | ctx := r.Context() 64 | 65 | signature := r.Header.Get(jwsSignatureHeaderName) 66 | if signature == "" { 67 | return nil, badRequestError("Netlify microservice headers missing") 68 | } 69 | 70 | claims := NetlifyMicroserviceClaims{} 71 | p := jwt.Parser{ValidMethods: []string{jwt.SigningMethodHS256.Name}} 72 | _, err := p.ParseWithClaims(signature, &claims, func(token *jwt.Token) (interface{}, error) { 73 | return []byte(api.config.OperatorToken), nil 74 | }) 75 | if err != nil { 76 | return nil, badRequestError("Operator microservice headers are invalid: %v", err) 77 | } 78 | 79 | instanceID := claims.InstanceID 80 | if instanceID == "" { 81 | return nil, badRequestError("Instance ID is missing") 82 | } 83 | 84 | logEntrySetField(r, "instance_id", instanceID) 85 | logEntrySetField(r, "netlify_id", claims.NetlifyID) 86 | instance, err := models.GetInstance(api.db, instanceID) 87 | if err != nil { 88 | if models.IsNotFoundError(err) { 89 | return nil, notFoundError("Unable to locate site configuration") 90 | } 91 | return nil, internalServerError("Database error loading instance").WithInternalError(err) 92 | } 93 | 94 | config, err := instance.Config() 95 | if err != nil { 96 | return nil, internalServerError("Error loading environment config").WithInternalError(err) 97 | } 98 | if claims.SiteURL != "" { 99 | config.SiteURL = claims.SiteURL 100 | } 101 | logEntrySetField(r, "site_url", config.SiteURL) 102 | 103 | ctx, err = WithInstanceConfig(ctx, api.config.SMTP, config, instanceID) 104 | if err != nil { 105 | return nil, internalServerError("Error loading instance config").WithInternalError(err) 106 | } 107 | 108 | return ctx, nil 109 | } 110 | 111 | func WithInstanceConfig(ctx context.Context, smtp conf.SMTPConfiguration, config *conf.Configuration, instanceID string) (context.Context, error) { 112 | ctx = gcontext.WithInstanceID(ctx, instanceID) 113 | ctx = gcontext.WithConfig(ctx, config) 114 | ctx, err := gcontext.WithCoupons(ctx, config) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | mailer := mailer.NewMailer(smtp, config) 120 | ctx = gcontext.WithMailer(ctx, mailer) 121 | 122 | store, err := assetstores.NewStore(config) 123 | if err != nil { 124 | return nil, errors.Wrap(err, "Error initializing asset store") 125 | } 126 | ctx = gcontext.WithAssetStore(ctx, store) 127 | 128 | provs, err := createPaymentProviders(config) 129 | if err != nil { 130 | return nil, errors.Wrap(err, "error creating payment providers") 131 | } 132 | if len(provs) == 0 { 133 | return nil, errors.New("No payment providers enabled") 134 | } 135 | ctx = gcontext.WithPaymentProviders(ctx, provs) 136 | 137 | return ctx, nil 138 | } 139 | -------------------------------------------------------------------------------- /api/middleware_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/netlify/gocommerce/calculator" 10 | "github.com/netlify/gocommerce/conf" 11 | "github.com/netlify/gocommerce/models" 12 | "github.com/pborman/uuid" 13 | "github.com/stretchr/testify/require" 14 | "github.com/stretchr/testify/suite" 15 | ) 16 | 17 | type MiddlewareTestSuite struct { 18 | suite.Suite 19 | API *API 20 | exampleSite *httptest.Server 21 | } 22 | 23 | func (ts *MiddlewareTestSuite) SetupTest() { 24 | globalConfig, log, err := conf.LoadGlobal("test.env") 25 | require.NoError(ts.T(), err) 26 | globalConfig.MultiInstanceMode = true 27 | db, err := models.Connect(globalConfig, log) 28 | require.NoError(ts.T(), err) 29 | 30 | api := NewAPI(globalConfig, log, db) 31 | ts.API = api 32 | } 33 | 34 | func (ts *MiddlewareTestSuite) TearDownTest() { 35 | if ts.exampleSite != nil { 36 | ts.exampleSite.Close() 37 | ts.exampleSite = nil 38 | } 39 | 40 | // Cleanup created instance 41 | i, err := models.GetInstanceByUUID(ts.API.db, testUUID) 42 | if err == nil { 43 | require.NoError(ts.T(), models.DeleteInstance(ts.API.db, i)) 44 | } 45 | } 46 | 47 | func (ts *MiddlewareTestSuite) setupInstance(config *conf.Configuration, siteSettings interface{}) string { 48 | if config == nil { 49 | _, config = testConfig() 50 | } 51 | 52 | if siteSettings == nil { 53 | siteSettings = struct{}{} 54 | } 55 | ts.exampleSite = startTestSiteWithSettings(siteSettings) 56 | config.SiteURL = ts.exampleSite.URL 57 | 58 | instanceID := uuid.NewRandom().String() 59 | err := models.CreateInstance(ts.API.db, &models.Instance{ 60 | ID: instanceID, 61 | UUID: testUUID, 62 | BaseConfig: config, 63 | }) 64 | require.NoError(ts.T(), err) 65 | 66 | return instanceID 67 | } 68 | 69 | func (ts *MiddlewareTestSuite) TestWithInstanceConfig() { 70 | instanceID := ts.setupInstance(nil, nil) 71 | 72 | req := httptest.NewRequest(http.MethodGet, "http://localhost/settings", nil) 73 | req.Header.Set("Content-Type", "application/json") 74 | err := signInstanceRequest(req, instanceID, ts.API.config.OperatorToken) 75 | require.NoError(ts.T(), err) 76 | 77 | w := httptest.NewRecorder() 78 | ts.API.handler.ServeHTTP(w, req) 79 | require.Equal(ts.T(), http.StatusOK, w.Code) 80 | 81 | settingsPayload := &calculator.Settings{ 82 | PricesIncludeTaxes: false, 83 | PaymentMethods: &calculator.PaymentMethods{}, 84 | } 85 | settingsPayload.PaymentMethods.Stripe.Enabled = true 86 | 87 | parsedBody := &calculator.Settings{} 88 | err = json.NewDecoder(w.Body).Decode(parsedBody) 89 | require.NoError(ts.T(), err) 90 | 91 | require.EqualValues(ts.T(), settingsPayload, parsedBody) 92 | } 93 | 94 | func (ts *MiddlewareTestSuite) TestWithInstanceConfig_NoPaymentProviders() { 95 | instanceID := ts.setupInstance(&conf.Configuration{}, nil) 96 | 97 | req := httptest.NewRequest(http.MethodGet, "http://localhost/settings", nil) 98 | req.Header.Set("Content-Type", "application/json") 99 | err := signInstanceRequest(req, instanceID, ts.API.config.OperatorToken) 100 | require.NoError(ts.T(), err) 101 | 102 | w := httptest.NewRecorder() 103 | ts.API.handler.ServeHTTP(w, req) 104 | require.Equal(ts.T(), http.StatusInternalServerError, w.Code) 105 | } 106 | 107 | func TestMiddleware(t *testing.T) { 108 | suite.Run(t, new(MiddlewareTestSuite)) 109 | } 110 | -------------------------------------------------------------------------------- /api/pagination.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | 9 | "github.com/jinzhu/gorm" 10 | ) 11 | 12 | const defaultPerPage = 50 13 | 14 | func calculateTotalPages(perPage, total uint64) uint64 { 15 | pages := total / perPage 16 | if total%perPage > 0 { 17 | return pages + 1 18 | } 19 | return pages 20 | } 21 | 22 | func addPaginationHeaders(w http.ResponseWriter, r *http.Request, page, perPage, total uint64) { 23 | totalPages := calculateTotalPages(perPage, total) 24 | url, _ := url.ParseRequestURI(r.URL.RequestURI()) 25 | query := url.Query() 26 | header := "" 27 | if totalPages > page { 28 | query.Set("page", fmt.Sprintf("%v", page+1)) 29 | url.RawQuery = query.Encode() 30 | header += "<" + url.String() + ">; rel=\"next\", " 31 | } 32 | query.Set("page", fmt.Sprintf("%v", totalPages)) 33 | url.RawQuery = query.Encode() 34 | header += "<" + url.String() + ">; rel=\"last\"" 35 | 36 | w.Header().Add("Link", header) 37 | w.Header().Add("X-Total-Count", fmt.Sprintf("%v", total)) 38 | } 39 | 40 | func paginate(w http.ResponseWriter, r *http.Request, query *gorm.DB) (offset int, limit int, err error) { 41 | params := r.URL.Query() 42 | queryPage := params.Get("page") 43 | queryPerPage := params.Get("per_page") 44 | var page uint64 = 1 45 | var perPage uint64 = defaultPerPage 46 | if queryPage != "" { 47 | page, err = strconv.ParseUint(queryPage, 10, 64) 48 | if err != nil { 49 | return 50 | } 51 | } 52 | if queryPerPage != "" { 53 | perPage, err = strconv.ParseUint(queryPerPage, 10, 64) 54 | if err != nil { 55 | return 56 | } 57 | } 58 | 59 | var total uint64 60 | if result := query.Count(&total); result.Error != nil { 61 | err = result.Error 62 | return 63 | } 64 | 65 | offset = int((page - 1) * perPage) 66 | limit = int(perPage) 67 | addPaginationHeaders(w, r, page, perPage, total) 68 | 69 | return 70 | } 71 | -------------------------------------------------------------------------------- /api/params.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/jinzhu/gorm" 11 | "github.com/netlify/gocommerce/models" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type sortDirection string 16 | 17 | const ascending sortDirection = "asc" 18 | const descending sortDirection = "desc" 19 | 20 | var sortFields = map[string]string{ 21 | "created_at": "created_at", 22 | "updated_at": "updated_at", 23 | "email": "email", 24 | "taxes": "taxes", 25 | "subtotal": "subtotal", 26 | "total": "total", 27 | } 28 | 29 | func parsePaymentQueryParams(query *gorm.DB, params url.Values) (*gorm.DB, error) { 30 | transactionTable := query.NewScope(models.Transaction{}).QuotedTableName() 31 | query = addFilters(query, transactionTable, params, []string{ 32 | "processor_id", 33 | "user_id", 34 | "order_id", 35 | "failure_code", 36 | "currency", 37 | "type", 38 | "status", 39 | }) 40 | 41 | if values, exists := params["min_amount"]; exists { 42 | query = query.Where(transactionTable+".amount >= ?", values[0]) 43 | } 44 | 45 | if values, exists := params["max_amount"]; exists { 46 | query = query.Where(transactionTable+".amount <= ?", values[0]) 47 | } 48 | 49 | query, err := parseLimitQueryParam(query, params) 50 | if err != nil { 51 | return nil, err 52 | } 53 | return parseTimeQueryParams(query, transactionTable, params) 54 | } 55 | 56 | func parseUserBulkDeleteParams(query *gorm.DB, params url.Values) (*gorm.DB, error) { 57 | if _, ok := params["id"]; !ok { 58 | return nil, errors.New("User ID field is required") 59 | } 60 | 61 | userTable := query.NewScope(models.User{}).QuotedTableName() 62 | query = addFilters(query, userTable, params, []string{ 63 | "id", 64 | }) 65 | return query, nil 66 | } 67 | 68 | func parseUserQueryParams(query *gorm.DB, params url.Values) (*gorm.DB, error) { 69 | userTable := query.NewScope(models.User{}).QuotedTableName() 70 | query = addFilters(query, userTable, params, []string{ 71 | "id", 72 | }) 73 | 74 | query = addLikeFilters(query, userTable, params, []string{ 75 | "email", 76 | }) 77 | 78 | query, err := parseLimitQueryParam(query, params) 79 | if err != nil { 80 | return nil, err 81 | } 82 | return parseTimeQueryParams(query, userTable, params) 83 | } 84 | 85 | func sortField(value string) string { 86 | return sortFields[value] 87 | } 88 | 89 | func addAddressFilter(query *gorm.DB, params url.Values, queryField string, dbField string) *gorm.DB { 90 | addressTable := query.NewScope(models.Address{}).QuotedTableName() 91 | orderTable := query.NewScope(models.Order{}).QuotedTableName() 92 | 93 | if billingField := params.Get("billing_" + queryField); billingField != "" { 94 | statement := "JOIN " + addressTable + " as billing_address on billing_address.id = " + 95 | orderTable + ".billing_address_id AND " + "billing_address." + dbField + " in (?)" 96 | query = query.Joins(statement, strings.Split(billingField, ",")) 97 | } 98 | 99 | if shippingField := params.Get("shipping_" + queryField); shippingField != "" { 100 | statement := "JOIN " + addressTable + " as shipping_address on shipping_address.id = " + 101 | orderTable + ".shipping_address_id AND " + "shipping_address." + dbField + " in (?)" 102 | query = query.Joins(statement, strings.Split(shippingField, ",")) 103 | } 104 | return query 105 | } 106 | 107 | // addNegativeAddressFilter allows filtering with a negative query arg like "?shipping_countries!=Germany" 108 | func addNegativeAddressFilter(query *gorm.DB, params url.Values, queryField string, dbField string) *gorm.DB { 109 | addressTable := query.NewScope(models.Address{}).QuotedTableName() 110 | orderTable := query.NewScope(models.Order{}).QuotedTableName() 111 | 112 | if billingField := params.Get("billing_" + queryField + "!"); billingField != "" { 113 | statement := "JOIN " + addressTable + " as billing_address on billing_address.id = " + 114 | orderTable + ".billing_address_id AND " + "billing_address." + dbField + " not in (?)" 115 | query = query.Joins(statement, strings.Split(billingField, ",")) 116 | } 117 | 118 | if shippingField := params.Get("shipping_" + queryField + "!"); shippingField != "" { 119 | statement := "JOIN " + addressTable + " as shipping_address on shipping_address.id = " + 120 | orderTable + ".shipping_address_id AND " + "shipping_address." + dbField + " not in (?)" 121 | query = query.Joins(statement, strings.Split(shippingField, ",")) 122 | } 123 | return query 124 | } 125 | 126 | func parseOrderParams(query *gorm.DB, params url.Values) (*gorm.DB, error) { 127 | orderTable := query.NewScope(models.Order{}).QuotedTableName() 128 | 129 | if tax := params.Get("tax"); tax != "" { 130 | if tax == "yes" || tax == "true" { 131 | query = query.Where(orderTable + ".taxes > 0") 132 | } else { 133 | query = query.Where(orderTable + ".taxes = 0") 134 | } 135 | } 136 | 137 | query = addAddressFilter(query, params, "countries", "country") 138 | query = addNegativeAddressFilter(query, params, "countries", "country") 139 | query = addAddressFilter(query, params, "name", "name") 140 | 141 | if values, exists := params["sort"]; exists { 142 | for _, value := range values { 143 | parts := strings.Split(value, " ") 144 | field := sortField(parts[0]) 145 | if field == "" { 146 | return nil, fmt.Errorf("bad field for sort '%v'", field) 147 | } 148 | dir := ascending 149 | if len(parts) == 2 { 150 | switch strings.ToLower(parts[1]) { 151 | case string(ascending): 152 | dir = ascending 153 | case string(descending): 154 | dir = descending 155 | default: 156 | return nil, fmt.Errorf("bad direction for sort '%v', only 'asc' and 'desc' allowed", parts[1]) 157 | } 158 | } 159 | query = query.Order(field + " " + string(dir)) 160 | } 161 | } else { 162 | query = query.Order("created_at desc") 163 | } 164 | 165 | if items := params.Get("items"); items != "" { 166 | lineItemTable := query.NewScope(models.LineItem{}).QuotedTableName() 167 | statement := "JOIN " + lineItemTable + " as line_item on line_item.order_id = " + 168 | orderTable + ".id AND line_item.title LIKE ?" 169 | query = query.Joins(statement, "%"+items+"%") 170 | } 171 | 172 | if itemType := params.Get("item_type"); itemType != "" { 173 | lineItemTable := query.NewScope(models.LineItem{}).QuotedTableName() 174 | statement := "JOIN " + lineItemTable + " as line_item on line_item.order_id = " + 175 | orderTable + ".id AND line_item.type LIKE ?" 176 | query = query.Joins(statement, "%"+itemType+"%") 177 | } 178 | 179 | query, err := addFilterChoices(query, orderTable, params, "payment_state", models.PaymentStates) 180 | if err != nil { 181 | return nil, err 182 | } 183 | query, err = addFilterChoices(query, orderTable, params, "fulfillment_state", models.FulfillmentStates) 184 | if err != nil { 185 | return nil, err 186 | } 187 | 188 | query = addFilters(query, orderTable, params, []string{ 189 | "invoice_number", 190 | }) 191 | 192 | query = addLikeFilters(query, orderTable, params, []string{ 193 | "email", 194 | "coupon_code", 195 | }) 196 | 197 | return parseTimeQueryParams(query, orderTable, params) 198 | } 199 | 200 | func parseLimitQueryParam(query *gorm.DB, params url.Values) (*gorm.DB, error) { 201 | if values, exists := params["limit"]; exists { 202 | v, err := strconv.Atoi(values[0]) 203 | if err != nil { 204 | return nil, err 205 | } 206 | query = query.Limit(v) 207 | } 208 | 209 | return query, nil 210 | } 211 | 212 | func getTimeQueryParams(params url.Values) (from *time.Time, to *time.Time, err error) { 213 | if value := params.Get("from"); value != "" { 214 | ts, err := strconv.ParseInt(value, 10, 64) 215 | if err != nil { 216 | return from, to, fmt.Errorf("bad value for 'from' parameter: %s", err) 217 | } 218 | t := time.Unix(ts, 0) 219 | from = &t 220 | } 221 | 222 | if value := params.Get("to"); value != "" { 223 | ts, err := strconv.ParseInt(value, 10, 64) 224 | if err != nil { 225 | return from, to, fmt.Errorf("bad value for 'to' parameter: %s", err) 226 | } 227 | t := time.Unix(ts, 0) 228 | to = &t 229 | } 230 | return 231 | } 232 | 233 | func parseTimeQueryParams(query *gorm.DB, tableName string, params url.Values) (*gorm.DB, error) { 234 | from, to, err := getTimeQueryParams(params) 235 | if err != nil { 236 | return nil, err 237 | } 238 | if from != nil { 239 | query = query.Where(tableName+".created_at >= ?", from) 240 | } 241 | if to != nil { 242 | query = query.Where(tableName+".created_at <= ?", to) 243 | } 244 | return query, nil 245 | } 246 | 247 | func addFilters(query *gorm.DB, table string, params url.Values, availableFilters []string) *gorm.DB { 248 | for _, filter := range availableFilters { 249 | if values, exists := params[filter]; exists { 250 | query = query.Where(table+"."+filter+" IN (?)", values) 251 | } 252 | } 253 | return query 254 | } 255 | 256 | func addLikeFilters(query *gorm.DB, table string, params url.Values, availableFilters []string) *gorm.DB { 257 | for _, filter := range availableFilters { 258 | if values, exists := params[filter]; exists { 259 | query = query.Where(table+"."+filter+" LIKE ?", "%"+values[0]+"%") 260 | } 261 | } 262 | return query 263 | } 264 | 265 | func addFilterChoices(query *gorm.DB, table string, params url.Values, filterField string, choices []string) (*gorm.DB, error) { 266 | values, exists := params[filterField] 267 | if !exists { 268 | return query, nil 269 | } 270 | 271 | filterValues := []string{} 272 | for _, q := range values { 273 | filterValue := "" 274 | for _, v := range choices { 275 | if q == v { 276 | filterValue = v 277 | break 278 | } 279 | } 280 | if filterValue == "" { 281 | return query, fmt.Errorf("Value for %s is not supported: %s", filterField, q) 282 | } 283 | filterValues = append(filterValues, filterValue) 284 | } 285 | 286 | return query.Where(fmt.Sprintf("%s.%s IN (?)", table, filterField), filterValues), nil 287 | } 288 | -------------------------------------------------------------------------------- /api/reports.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | gcontext "github.com/netlify/gocommerce/context" 7 | "github.com/netlify/gocommerce/models" 8 | ) 9 | 10 | type salesRow struct { 11 | Total uint64 `json:"total"` 12 | SubTotal uint64 `json:"subtotal"` 13 | Taxes uint64 `json:"taxes"` 14 | Currency string `json:"currency"` 15 | Orders uint64 `json:"orders"` 16 | } 17 | 18 | type productsRow struct { 19 | Sku string `json:"sku"` 20 | Path string `json:"path"` 21 | Total uint64 `json:"total"` 22 | Currency string `json:"currency"` 23 | } 24 | 25 | // SalesReport lists the sales numbers for a period 26 | func (a *API) SalesReport(w http.ResponseWriter, r *http.Request) error { 27 | instanceID := gcontext.GetInstanceID(r.Context()) 28 | 29 | query := a.DB(r). 30 | Model(&models.Order{}). 31 | Select("sum(total) as total, sum(sub_total) as subtotal, sum(taxes) as taxes, currency, count(*) as orders"). 32 | Where("payment_state = 'paid' AND instance_id = ?", instanceID). 33 | Group("currency") 34 | 35 | query, err := parseTimeQueryParams(query, query.NewScope(models.Order{}).QuotedTableName(), r.URL.Query()) 36 | if err != nil { 37 | return badRequestError(err.Error()) 38 | } 39 | 40 | rows, err := query.Rows() 41 | if err != nil { 42 | return internalServerError("Database error").WithInternalError(err) 43 | } 44 | defer rows.Close() 45 | result := []*salesRow{} 46 | for rows.Next() { 47 | row := &salesRow{} 48 | err = rows.Scan(&row.Total, &row.SubTotal, &row.Taxes, &row.Currency, &row.Orders) 49 | if err != nil { 50 | return internalServerError("Database error").WithInternalError(err) 51 | } 52 | result = append(result, row) 53 | } 54 | 55 | return sendJSON(w, http.StatusOK, result) 56 | } 57 | 58 | // ProductsReport list the products sold within a period 59 | func (a *API) ProductsReport(w http.ResponseWriter, r *http.Request) error { 60 | db := a.DB(r) 61 | instanceID := gcontext.GetInstanceID(r.Context()) 62 | ordersTable := db.NewScope(models.Order{}).QuotedTableName() 63 | itemsTable := db.NewScope(models.LineItem{}).QuotedTableName() 64 | query := db. 65 | Model(&models.LineItem{}). 66 | Select("sku, path, sum(quantity * price) as total, currency"). 67 | Joins("JOIN " + ordersTable + " ON " + ordersTable + ".id = " + itemsTable + ".order_id " + "AND " + ordersTable + ".payment_state = 'paid'"). 68 | Group("sku, path, currency"). 69 | Order("total desc") 70 | 71 | query = query.Where(ordersTable+".instance_id = ?", instanceID) 72 | from, to, err := getTimeQueryParams(r.URL.Query()) 73 | if err != nil { 74 | return badRequestError(err.Error()) 75 | } 76 | if from != nil { 77 | query = query.Where(ordersTable+".created_at >= ?", from) 78 | } 79 | if to != nil { 80 | query.Where(ordersTable+".created_at <= ?", to) 81 | } 82 | 83 | rows, err := query.Rows() 84 | if err != nil { 85 | return internalServerError("Database error").WithInternalError(err) 86 | } 87 | defer rows.Close() 88 | result := []*productsRow{} 89 | for rows.Next() { 90 | row := &productsRow{} 91 | err = rows.Scan(&row.Sku, &row.Path, &row.Total, &row.Currency) 92 | if err != nil { 93 | return internalServerError("Database error").WithInternalError(err) 94 | } 95 | result = append(result, row) 96 | } 97 | 98 | return sendJSON(w, http.StatusOK, result) 99 | } 100 | -------------------------------------------------------------------------------- /api/reports_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSalesReport(t *testing.T) { 11 | t.Run("AllTime", func(t *testing.T) { 12 | test := NewRouteTest(t) 13 | token := testAdminToken("admin-yo", "admin@wayneindustries.com") 14 | recorder := test.TestEndpoint(http.MethodGet, "/reports/sales", nil, token) 15 | 16 | report := []salesRow{} 17 | extractPayload(t, http.StatusOK, recorder, &report) 18 | assert.Len(t, report, 1) 19 | row := report[0] 20 | assert.Equal(t, uint64(79), row.Total) 21 | assert.Equal(t, uint64(79), row.SubTotal) 22 | assert.Equal(t, uint64(0), row.Taxes) 23 | assert.Equal(t, "USD", row.Currency) 24 | assert.Equal(t, uint64(2), row.Orders) 25 | }) 26 | } 27 | 28 | func TestProductsReport(t *testing.T) { 29 | test := NewRouteTest(t) 30 | token := testAdminToken("admin-yo", "admin@wayneindustries.com") 31 | recorder := test.TestEndpoint(http.MethodGet, "/reports/products", nil, token) 32 | 33 | report := []productsRow{} 34 | extractPayload(t, http.StatusOK, recorder, &report) 35 | assert.Len(t, report, 3) 36 | prod1 := report[0] 37 | assert.Equal(t, "234-fancy-belts", prod1.Sku) 38 | assert.Equal(t, uint64(45), prod1.Total) 39 | prod2 := report[1] 40 | assert.Equal(t, "123-i-can-fly-456", prod2.Sku) 41 | assert.Equal(t, uint64(24), prod2.Total) 42 | prod3 := report[2] 43 | assert.Equal(t, "456-i-rollover-all-things", prod3.Sku) 44 | assert.Equal(t, uint64(10), prod3.Total) 45 | } 46 | -------------------------------------------------------------------------------- /api/router.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi" 8 | ) 9 | 10 | func newRouter() *router { 11 | return &router{chi.NewRouter()} 12 | } 13 | 14 | type router struct { 15 | chi chi.Router 16 | } 17 | 18 | func (r *router) Route(pattern string, fn func(*router)) { 19 | r.chi.Route(pattern, func(c chi.Router) { 20 | fn(&router{c}) 21 | }) 22 | } 23 | 24 | func (r *router) Get(pattern string, fn apiHandler) { 25 | r.chi.Get(pattern, handler(fn)) 26 | } 27 | func (r *router) Post(pattern string, fn apiHandler) { 28 | r.chi.Post(pattern, handler(fn)) 29 | } 30 | func (r *router) Put(pattern string, fn apiHandler) { 31 | r.chi.Put(pattern, handler(fn)) 32 | } 33 | func (r *router) Delete(pattern string, fn apiHandler) { 34 | r.chi.Delete(pattern, handler(fn)) 35 | } 36 | 37 | func (r *router) With(fn middlewareHandler) *router { 38 | c := r.chi.With(middleware(fn)) 39 | return &router{c} 40 | } 41 | 42 | func (r *router) WithBypass(fn func(next http.Handler) http.Handler) *router { 43 | c := r.chi.With(fn) 44 | return &router{c} 45 | } 46 | 47 | func (r *router) Use(fn middlewareHandler) { 48 | r.chi.Use(middleware(fn)) 49 | } 50 | func (r *router) UseBypass(fn func(next http.Handler) http.Handler) { 51 | r.chi.Use(fn) 52 | } 53 | 54 | func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { 55 | r.chi.ServeHTTP(w, req) 56 | } 57 | 58 | type apiHandler func(w http.ResponseWriter, r *http.Request) error 59 | 60 | func handler(fn apiHandler) http.HandlerFunc { 61 | return fn.serve 62 | } 63 | 64 | func (h apiHandler) serve(w http.ResponseWriter, r *http.Request) { 65 | if err := h(w, r); err != nil { 66 | handleError(err, w, r) 67 | } 68 | } 69 | 70 | type middlewareHandler func(w http.ResponseWriter, r *http.Request) (context.Context, error) 71 | 72 | func (m middlewareHandler) handler(next http.Handler) http.Handler { 73 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 74 | m.serve(next, w, r) 75 | }) 76 | } 77 | 78 | func (m middlewareHandler) serve(next http.Handler, w http.ResponseWriter, r *http.Request) { 79 | ctx, err := m(w, r) 80 | if err != nil { 81 | handleError(err, w, r) 82 | return 83 | } 84 | if ctx != nil { 85 | r = r.WithContext(ctx) 86 | } 87 | next.ServeHTTP(w, r) 88 | } 89 | 90 | func middleware(fn middlewareHandler) func(http.Handler) http.Handler { 91 | return fn.handler 92 | } 93 | -------------------------------------------------------------------------------- /api/settings.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/netlify/gocommerce/calculator" 8 | gcontext "github.com/netlify/gocommerce/context" 9 | ) 10 | 11 | func (a *API) ViewSettings(w http.ResponseWriter, r *http.Request) error { 12 | ctx := r.Context() 13 | config := gcontext.GetConfig(ctx) 14 | 15 | settings, err := a.loadSettings(ctx) 16 | if err != nil { 17 | return fmt.Errorf("Error loading site settings: %v", err) 18 | } 19 | 20 | pms := &calculator.PaymentMethods{} 21 | if config.Payment.Stripe.Enabled { 22 | pms.Stripe.Enabled = true 23 | pms.Stripe.PublicKey = config.Payment.Stripe.PublicKey 24 | } 25 | if config.Payment.PayPal.Enabled { 26 | pms.PayPal.Enabled = true 27 | pms.PayPal.ClientID = config.Payment.PayPal.ClientID 28 | pms.PayPal.Environment = config.Payment.PayPal.Env 29 | } 30 | settings.PaymentMethods = pms 31 | 32 | sendJSON(w, 200, settings) 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /api/test.env: -------------------------------------------------------------------------------- 1 | GOCOMMERCE_JWT_SECRET=testsecret 2 | GOCOMMERCE_JWT_EXP=3600 3 | GOCOMMERCE_JWT_AUD=api.netlify.com 4 | GOCOMMERCE_DB_DRIVER=sqlite3 5 | GOCOMMERCE_DB_AUTOMIGRATE=true 6 | GOCOMMERCE_DB_DATABASE_URL=test.db 7 | GOCOMMERCE_DB_NAMESPACE=test 8 | GOCOMMERCE_API_HOST=localhost 9 | PORT=9999 10 | GOCOMMERCE_LOG_LEVEL=error 11 | GOCOMMERCE_SITE_URL=https://example.netlify.com 12 | GOCOMMERCE_OPERATOR_TOKEN=foobar 13 | -------------------------------------------------------------------------------- /api/user.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "encoding/json" 7 | "net/http" 8 | 9 | "github.com/go-chi/chi" 10 | "github.com/jinzhu/gorm" 11 | "github.com/pborman/uuid" 12 | 13 | "github.com/netlify/gocommerce/claims" 14 | gcontext "github.com/netlify/gocommerce/context" 15 | "github.com/netlify/gocommerce/models" 16 | ) 17 | 18 | func (a *API) withUser(w http.ResponseWriter, r *http.Request) (context.Context, error) { 19 | userID := chi.URLParam(r, "user_id") 20 | logEntrySetField(r, "user_id", userID) 21 | ctx := r.Context() 22 | 23 | if u, err := models.GetUser(a.DB(r), userID); err != nil { 24 | return nil, internalServerError("problem while querying for userID: %s", userID).WithInternalError(err) 25 | } else if u != nil { 26 | ctx = gcontext.WithUser(ctx, u) 27 | } 28 | 29 | ctx = gcontext.WithUserID(ctx, userID) 30 | return ctx, nil 31 | } 32 | 33 | func findUserName(order *models.Order, claims *claims.JWTClaims) string { 34 | if rawName, ok := claims.UserMetaData["full_name"]; ok { 35 | if name, ok := rawName.(string); ok { 36 | return name 37 | } 38 | } 39 | if order.BillingAddress.Name != "" { 40 | return order.BillingAddress.Name 41 | } 42 | if order.ShippingAddress.Name != "" { 43 | return order.ShippingAddress.Name 44 | } 45 | return "" 46 | } 47 | 48 | // persistUserName will set a users name from a JWT or the order addresses 49 | func persistUserName(tx *gorm.DB, order *models.Order, claims *claims.JWTClaims) *HTTPError { 50 | if claims == nil { 51 | return nil 52 | } 53 | 54 | if claims.Subject == "" { 55 | return badRequestError("Token had an invalid ID: %s", claims.Subject) 56 | } 57 | 58 | user := models.User{} 59 | if err := tx.Find(&user, "id = ?", claims.Subject).Error; err != nil { 60 | return internalServerError("User has not been created yet. This is unexpected behavior"). 61 | WithInternalError(err) 62 | } 63 | 64 | name := findUserName(order, claims) 65 | if name != "" { 66 | user.Name = name 67 | if err := tx.Save(&user).Error; err != nil { 68 | return internalServerError("Unable to save user's name").WithInternalError(err) 69 | } 70 | } 71 | 72 | return nil 73 | } 74 | 75 | // UserList will return all of the users. It requires admin access. 76 | // It supports the filters: 77 | // since iso8601 date 78 | // before iso8601 date 79 | // email email 80 | // user_id id 81 | // limit # of records to return (max) 82 | func (a *API) UserList(w http.ResponseWriter, r *http.Request) error { 83 | log := getLogEntry(r) 84 | db := a.DB(r) 85 | 86 | query, err := parseUserQueryParams(db, r.URL.Query()) 87 | if err != nil { 88 | return badRequestError("Bad parameters in query: %v", err) 89 | } 90 | log.Debug("Parsed url params") 91 | 92 | orderTable := db.NewScope(models.Order{}).QuotedTableName() 93 | userTable := db.NewScope(models.User{}).QuotedTableName() 94 | query = query. 95 | Joins("LEFT JOIN " + orderTable + " ON " + userTable + ".id = " + orderTable + ".user_id"). 96 | Group(userTable + ".id") 97 | 98 | instanceID := gcontext.GetInstanceID(r.Context()) 99 | query = query.Where(userTable+".instance_id = ?", instanceID) 100 | 101 | offset, limit, err := paginate(w, r, query.Model(&models.User{})) 102 | if err != nil { 103 | if err == sql.ErrNoRows { 104 | return sendJSON(w, http.StatusOK, []string{}) 105 | } 106 | return badRequestError("Bad Pagination Parameters: %v", err) 107 | } 108 | 109 | query = query.Select("" + 110 | "COUNT(" + orderTable + ".id) AS order_count, " + 111 | "MAX(" + orderTable + ".created_at) AS last_order_at, " + 112 | userTable + ".*") 113 | 114 | users := []models.User{} 115 | if err := query.Offset(offset).Limit(limit).Find(&users).Error; err != nil { 116 | return internalServerError("Failed to execute request").WithInternalError(err) 117 | } 118 | 119 | numUsers := len(users) 120 | log.WithField("user_count", numUsers).Debugf("Successfully retrieved %d users", numUsers) 121 | return sendJSON(w, http.StatusOK, users) 122 | } 123 | 124 | // UserView will return the user specified. 125 | // If you're an admin you can request a user that is not your self 126 | func (a *API) UserView(w http.ResponseWriter, r *http.Request) error { 127 | ctx := r.Context() 128 | userID := gcontext.GetUserID(ctx) 129 | user := gcontext.GetUser(ctx) 130 | if user == nil { 131 | return notFoundError("Couldn't find a record for " + userID) 132 | } 133 | 134 | orders := []models.Order{} 135 | a.DB(r).Where("user_id = ?", user.ID).Find(&orders).Count(&user.OrderCount) 136 | 137 | return sendJSON(w, http.StatusOK, user) 138 | } 139 | 140 | // AddressList will return the addresses for a given user 141 | func (a *API) AddressList(w http.ResponseWriter, r *http.Request) error { 142 | ctx := r.Context() 143 | userID := gcontext.GetUserID(ctx) 144 | user := gcontext.GetUser(ctx) 145 | if user == nil { 146 | return notFoundError("Couldn't find a record for " + userID) 147 | } 148 | 149 | addrs := []models.Address{} 150 | results := a.DB(r).Where("user_id = ?", userID).Find(&addrs) 151 | if results.Error != nil { 152 | return internalServerError("problem while querying for userID: %s", userID).WithInternalError(results.Error) 153 | } 154 | 155 | return sendJSON(w, http.StatusOK, &addrs) 156 | } 157 | 158 | // AddressView will return a particular address for a given user 159 | func (a *API) AddressView(w http.ResponseWriter, r *http.Request) error { 160 | ctx := r.Context() 161 | addrID := chi.URLParam(r, "addr_id") 162 | userID := gcontext.GetUserID(ctx) 163 | user := gcontext.GetUser(ctx) 164 | if user == nil { 165 | return notFoundError("Couldn't find a record for " + userID) 166 | } 167 | 168 | addr := &models.Address{ 169 | ID: addrID, 170 | UserID: userID, 171 | } 172 | results := a.DB(r).First(addr) 173 | if results.Error != nil { 174 | return internalServerError("problem while querying for userID: %s", userID).WithInternalError(results.Error) 175 | } 176 | 177 | return sendJSON(w, http.StatusOK, &addr) 178 | } 179 | 180 | // UserDelete will soft delete the user. It requires admin access 181 | // return errors or 200 and no body 182 | func (a *API) UserDelete(w http.ResponseWriter, r *http.Request) error { 183 | ctx := r.Context() 184 | userID := gcontext.GetUserID(ctx) 185 | log := getLogEntry(r) 186 | log.Debugf("Starting to delete user %s", userID) 187 | 188 | user := gcontext.GetUser(ctx) 189 | if user == nil { 190 | log.Info("attempted to delete non-existent user") 191 | return nil 192 | } 193 | 194 | rsp := a.DB(r).Delete(user) 195 | if rsp.Error != nil { 196 | return internalServerError("error while deleting user").WithInternalError(rsp.Error) 197 | } 198 | 199 | log.Infof("Deleted user") 200 | return nil 201 | } 202 | 203 | func (a *API) UserBulkDelete(w http.ResponseWriter, r *http.Request) error { 204 | log := getLogEntry(r) 205 | db := a.DB(r) 206 | 207 | query, err := parseUserBulkDeleteParams(db, r.URL.Query()) 208 | if err != nil { 209 | return badRequestError("Bad parameters in query: %v", err) 210 | } 211 | 212 | users := []models.User{} 213 | if result := query.Find(&users); result.Error != nil { 214 | return internalServerError("error while deleting user").WithInternalError(result.Error) 215 | } 216 | 217 | tx := db.Begin() 218 | defer func() { 219 | if r := recover(); r != nil { 220 | tx.Rollback() 221 | } 222 | }() 223 | 224 | for _, user := range users { 225 | if result := tx.Delete(&user); result.Error != nil { 226 | if result.RecordNotFound() { 227 | continue 228 | } 229 | tx.Rollback() 230 | return internalServerError("error while deleting user").WithInternalError(result.Error) 231 | } 232 | } 233 | 234 | log.Infof("Deleted users") 235 | return tx.Commit().Error 236 | } 237 | 238 | // AddressDelete will soft delete the address associated with that user. It requires admin access 239 | // return errors or 200 and no body 240 | func (a *API) AddressDelete(w http.ResponseWriter, r *http.Request) error { 241 | ctx := r.Context() 242 | addrID := chi.URLParam(r, "addr_id") 243 | log := getLogEntry(r).WithField("addr_id", addrID) 244 | 245 | user := gcontext.GetUser(ctx) 246 | if user == nil { 247 | log.Warn("requested non-existent user - not an error b/c it is a delete") 248 | return nil 249 | } 250 | 251 | rsp := a.DB(r).Delete(&models.Address{ID: addrID}) 252 | if rsp.RecordNotFound() { 253 | log.Warn("Attempted to delete an address that doesn't exist") 254 | return nil 255 | } else if rsp.Error != nil { 256 | return internalServerError("error while deleting address").WithInternalError(rsp.Error) 257 | } 258 | 259 | log.Info("deleted address") 260 | return nil 261 | } 262 | 263 | // CreateNewAddress will create an address associated with that user 264 | func (a *API) CreateNewAddress(w http.ResponseWriter, r *http.Request) error { 265 | ctx := r.Context() 266 | userID := gcontext.GetUserID(ctx) 267 | user := gcontext.GetUser(ctx) 268 | if user == nil { 269 | return notFoundError("Couldn't find a record for " + userID) 270 | } 271 | 272 | addrReq := new(models.AddressRequest) 273 | err := json.NewDecoder(r.Body).Decode(addrReq) 274 | if err != nil { 275 | return badRequestError("Failed to parse json body: %v", err) 276 | } 277 | 278 | if err := addrReq.Validate(); err != nil { 279 | return badRequestError("requested address is missing a required field: %v", err) 280 | } 281 | 282 | addr := models.Address{ 283 | AddressRequest: *addrReq, 284 | ID: uuid.NewRandom().String(), 285 | UserID: userID, 286 | } 287 | rsp := a.DB(r).Create(&addr) 288 | if rsp.Error != nil { 289 | return internalServerError("failed to save address").WithInternalError(rsp.Error) 290 | } 291 | 292 | return sendJSON(w, http.StatusOK, &struct{ ID string }{ID: addr.ID}) 293 | } 294 | -------------------------------------------------------------------------------- /api/vatnumbers.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi" 7 | "github.com/mattes/vat" 8 | ) 9 | 10 | // VatNumberLookup looks up information on a VAT number 11 | func (a *API) VatNumberLookup(w http.ResponseWriter, r *http.Request) error { 12 | number := chi.URLParam(r, "vat_number") 13 | 14 | response, err := vat.CheckVAT(number) 15 | if err != nil { 16 | return internalServerError("Failed to lookup VAT Number").WithInternalError(err) 17 | } 18 | 19 | return sendJSON(w, http.StatusOK, map[string]interface{}{ 20 | "country": response.CountryCode, 21 | "valid": response.Valid, 22 | "company": response.Name, 23 | "address": response.Address, 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GoCommerce", 3 | "description": "A lightweight Go-based API for e-commerce sites on the JAMstack", 4 | "website": "https://www.gocommerceapi.org", 5 | "repository": "https://github.com/netlify/gocommerce", 6 | "addons": ["heroku-postgresql"], 7 | "env": { 8 | "GOCOMMERCE_DB_DRIVER": { 9 | "value": "postgres" 10 | }, 11 | "GOCOMMERCE_DB_AUTOMIGRATE": { 12 | "value": "true" 13 | }, 14 | "GOCOMMERCE_JWT_SECRET": { 15 | "description": "A generated key you can use for making authenticated calls to the service", 16 | "generator": "secret" 17 | }, 18 | "GOCOMMERCE_SITE_URL": { 19 | "description": "The URL of the site where you will sell your products" 20 | }, 21 | "GOCOMMERCE_PAYMENT_STRIPE_ENABLED": { 22 | "required": false, 23 | "description": "Set to true to enable Stripe payment processing" 24 | }, 25 | "GOCOMMERCE_PAYMENT_STRIPE_PUBLIC_KEY": { 26 | "required": false, 27 | "description": "Required if Stripe is enabled. Get this key from Stripe." 28 | }, 29 | "GOCOMMERCE_PAYMENT_STRIPE_SECRET_KEY": { 30 | "required": false, 31 | "description": "Required if Stripe is enabled. Get this key from Stripe." 32 | }, 33 | "GOCOMMERCE_SMTP_HOST": { 34 | "required": false, 35 | "description": "Required for sending customer emails. Get from your transactional email provider. example: smtp.sparkpost.com" 36 | }, 37 | "GOCOMMERCE_SMTP_PORT": { 38 | "required": false, 39 | "description": "Required for sending customer emails. Get this port number from your transactional email provider." 40 | }, 41 | "GOCOMMERCE_SMTP_USER": { 42 | "required": false, 43 | "description": "Required for sending customer emails. Get this username from your transactional email provider." 44 | }, 45 | "GOCOMMERCE_SMTP_PASS": { 46 | "required": false, 47 | "description": "Required for sending customer emails. Get this password from your transactional email provider." 48 | }, 49 | "GOCOMMERCE_SMTP_ADMIN_EMAIL": { 50 | "required": false, 51 | "description": "Required for sending customer emails. This address will send customer emails and will receive order notifications." 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /assetstores/netlify.go: -------------------------------------------------------------------------------- 1 | package assetstores 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | type netlifyProvider struct { 14 | client *http.Client 15 | token string 16 | } 17 | 18 | func newNetlifyProvider(token string) (*netlifyProvider, error) { 19 | if token == "" { 20 | return nil, errors.New("No access token configured for Netlify") 21 | } 22 | 23 | return &netlifyProvider{ 24 | client: &http.Client{}, 25 | token: token, 26 | }, nil 27 | } 28 | 29 | type netlifySignature struct { 30 | URL string `json:"url"` 31 | } 32 | 33 | func (n *netlifyProvider) SignURL(downloadURL string) (string, error) { 34 | url, err := url.Parse(downloadURL) 35 | if err != nil { 36 | return "", err 37 | } 38 | if url.Host != "api.netlify.com" { 39 | return "", errors.New("Download URL didn't match Netlify API") 40 | } 41 | url.Scheme = "https" 42 | 43 | req, err := http.NewRequest("GET", url.String(), nil) 44 | if err != nil { 45 | return "", errors.Wrap(err, "Error creating signing request") 46 | } 47 | req.Header.Add("Authorization", "Bearer "+n.token) 48 | 49 | resp, err := n.client.Do(req) 50 | defer func() { 51 | if resp.Body != nil { 52 | resp.Body.Close() 53 | } 54 | }() 55 | if err != nil { 56 | return "", err 57 | } 58 | if resp.StatusCode != http.StatusOK { 59 | buf := new(bytes.Buffer) 60 | if _, err = buf.ReadFrom(resp.Body); err != nil { 61 | return "", fmt.Errorf("Error generating signature") 62 | } 63 | return "", fmt.Errorf("Error generating signature: %v", buf.String()) 64 | } 65 | signature := &netlifySignature{} 66 | if err := json.NewDecoder(resp.Body).Decode(signature); err != nil { 67 | return "", err 68 | } 69 | 70 | return signature.URL, nil 71 | } 72 | -------------------------------------------------------------------------------- /assetstores/noop.go: -------------------------------------------------------------------------------- 1 | package assetstores 2 | 3 | type noopProvider struct{} 4 | 5 | func newNoopProvider() (*noopProvider, error) { 6 | return &noopProvider{}, nil 7 | } 8 | 9 | func (n *noopProvider) SignURL(url string) (string, error) { 10 | return url, nil 11 | } 12 | -------------------------------------------------------------------------------- /assetstores/store.go: -------------------------------------------------------------------------------- 1 | package assetstores 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/netlify/gocommerce/conf" 7 | ) 8 | 9 | // Store is the interface wrapping an asset store that can sign download URLs. 10 | type Store interface { 11 | SignURL(string) (string, error) 12 | } 13 | 14 | // NewStore creates an asset store based on the provided configuration. 15 | func NewStore(config *conf.Configuration) (Store, error) { 16 | switch config.Downloads.Provider { 17 | case "netlify": 18 | return newNetlifyProvider(config.Downloads.NetlifyToken) 19 | case "": 20 | return newNoopProvider() 21 | default: 22 | return nil, fmt.Errorf("Unknown asset store provider '%v'", config.Downloads.Provider) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /calculator/discount_type.go: -------------------------------------------------------------------------------- 1 | package calculator 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | ) 7 | 8 | // DiscountType indicates what type of discount was given 9 | type DiscountType int 10 | 11 | // possible types for a discount item 12 | const ( 13 | DiscountTypeCoupon DiscountType = iota + 1 14 | DiscountTypeMember 15 | ) 16 | 17 | func (t DiscountType) String() string { 18 | switch t { 19 | case DiscountTypeCoupon: 20 | return "coupon" 21 | case DiscountTypeMember: 22 | return "member" 23 | } 24 | return "unknown" 25 | } 26 | 27 | // MarshalJSON marshals the enum as a quoted json string 28 | func (t DiscountType) MarshalJSON() ([]byte, error) { 29 | buffer := bytes.NewBufferString(`"`) 30 | buffer.WriteString(t.String()) 31 | buffer.WriteString(`"`) 32 | return buffer.Bytes(), nil 33 | } 34 | 35 | // UnmarshalJSON unmashals a quoted json string to the enum value 36 | func (t *DiscountType) UnmarshalJSON(b []byte) error { 37 | var j string 38 | err := json.Unmarshal(b, &j) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | switch j { 44 | case "coupon": 45 | *t = DiscountTypeCoupon 46 | case "member": 47 | *t = DiscountTypeMember 48 | default: 49 | *t = 0 50 | } 51 | return nil 52 | } 53 | -------------------------------------------------------------------------------- /calculator/test/settings_fixture.json: -------------------------------------------------------------------------------- 1 | { 2 | "prices_include_taxes": true, 3 | "taxes": [], 4 | "member_discounts": [ 5 | { 6 | "claims": {"app_metadata.subscription.plan": "member"}, 7 | "fixed": [{"amount": "15.00", "currency": "USD"}, {"amount": "15.00", "currency": "EUR"}], 8 | "product_types": ["Book"] 9 | }, 10 | { 11 | "claims": {"app_metadata.subscription.plan": "smashing"}, 12 | "fixed": [{"amount": "20.00", "currency": "USD"}, {"amount": "20.00", "currency": "EUR"}], 13 | "product_types": ["Book"] 14 | }, 15 | { 16 | "claims": {"app_metadata.subscription.plan": "smashing"}, 17 | "percentage": 100, 18 | "products": ["design-systems-by-alla-kholmatova", "the-smashing-library"] 19 | }, 20 | { 21 | "claims": {"app_metadata.subscription.plan": "member"}, 22 | "percentage": 100, 23 | "products": ["design-systems-ebook"] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /claims/claims.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import ( 4 | "strings" 5 | 6 | jwt "github.com/golang-jwt/jwt/v4" 7 | ) 8 | 9 | // JWTClaims represents the JWT claims information. 10 | type JWTClaims struct { 11 | Email string `json:"email"` 12 | AppMetaData map[string]interface{} `json:"app_metadata"` 13 | UserMetaData map[string]interface{} `json:"user_metadata"` 14 | jwt.StandardClaims 15 | } 16 | 17 | // HasClaims is used to determine if a set of userClaims matches the requiredClaims 18 | func HasClaims(userClaims map[string]interface{}, requiredClaims map[string]string) bool { 19 | if requiredClaims == nil { 20 | return true 21 | } 22 | if userClaims == nil { 23 | return false 24 | } 25 | 26 | for key, value := range requiredClaims { 27 | parts := strings.Split(key, ".") 28 | obj := userClaims 29 | for i, part := range parts { 30 | newObj, hasObj := obj[part] 31 | if !hasObj { 32 | return false 33 | } 34 | if i+1 == len(parts) { 35 | str, isString := newObj.(string) 36 | if !isString { 37 | return false 38 | } 39 | return str == value 40 | } 41 | obj, hasObj = newObj.(map[string]interface{}) 42 | if !hasObj { 43 | return false 44 | } 45 | 46 | } 47 | } 48 | return false 49 | } 50 | -------------------------------------------------------------------------------- /claims/claims_test.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestClaims(t *testing.T) { 12 | b, err := ioutil.ReadFile("test/jwt_payload_fixture.json") 13 | assert.NoError(t, err) 14 | 15 | var claims map[string]interface{} 16 | err = json.Unmarshal(b, &claims) 17 | 18 | required := map[string]string{ 19 | "app_metadata.subscription.plan": "smashing", 20 | } 21 | 22 | matches := HasClaims(claims, required) 23 | assert.True(t, matches) 24 | 25 | required = map[string]string{ 26 | "app_metadata.subscription.plan": "member", 27 | } 28 | 29 | matches = HasClaims(claims, required) 30 | assert.False(t, matches) 31 | } 32 | -------------------------------------------------------------------------------- /claims/test/jwt_payload_fixture.json: -------------------------------------------------------------------------------- 1 | { 2 | "exp": 1511383997, 3 | "sub": "68904e78-96c0-4de1-aa37-fa71b1790756", 4 | "email": "matt@netlify.com", 5 | "app_metadata": { 6 | "customer": { 7 | "id": "c_id" 8 | }, 9 | "provider": "email", 10 | "roles": [ 11 | "cms", 12 | "admin" 13 | ], 14 | "subscription": { 15 | "id": "sub_id", 16 | "plan": "smashing" 17 | } 18 | }, 19 | "user_metadata": { 20 | "firstname": "Matt", 21 | "full_name": "Matt Biilmann", 22 | "lastname": "Biilmann" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /cmd/migrate_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/netlify/gocommerce/conf" 5 | "github.com/netlify/gocommerce/models" 6 | "github.com/sirupsen/logrus" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var migrateCmd = cobra.Command{ 11 | Use: "migrate", 12 | Long: "Migrate database strucutures. This will create new tables and add missing collumns and indexes.", 13 | Run: func(cmd *cobra.Command, args []string) { 14 | execWithConfig(cmd, migrate) 15 | }, 16 | } 17 | 18 | func migrate(globalConfig *conf.GlobalConfiguration, log logrus.FieldLogger, config *conf.Configuration) { 19 | db, err := models.Connect(globalConfig, log) 20 | if err != nil { 21 | logrus.Fatalf("Error opening database: %+v", err) 22 | } 23 | defer db.Close() 24 | } 25 | -------------------------------------------------------------------------------- /cmd/multi_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/netlify/gocommerce/api" 8 | "github.com/netlify/gocommerce/conf" 9 | "github.com/netlify/gocommerce/models" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var multiCmd = cobra.Command{ 15 | Use: "multi", 16 | Long: "Start multi-tenant API server", 17 | Run: multi, 18 | } 19 | 20 | func multi(cmd *cobra.Command, args []string) { 21 | globalConfig, log, err := conf.LoadGlobal(configFile) 22 | if err != nil { 23 | logrus.Fatalf("Failed to load configuration: %+v", err) 24 | } 25 | if globalConfig.OperatorToken == "" { 26 | logrus.Fatal("Operator token secret is required") 27 | } 28 | 29 | db, err := models.Connect(globalConfig, log.WithField("component", "db")) 30 | if err != nil { 31 | logrus.Fatalf("Error opening database: %+v", err) 32 | } 33 | defer db.Close() 34 | 35 | bgDB, err := models.Connect(globalConfig, log.WithField("component", "db").WithField("bgdb", true)) 36 | if err != nil { 37 | logrus.Fatalf("Error opening database: %+v", err) 38 | } 39 | defer bgDB.Close() 40 | 41 | globalConfig.MultiInstanceMode = true 42 | api := api.NewAPIWithVersion(context.Background(), globalConfig, log, db.Debug(), Version) 43 | 44 | l := fmt.Sprintf("%v:%v", globalConfig.API.Host, globalConfig.API.Port) 45 | logrus.Infof("GoCommerce API started on: %s", l) 46 | 47 | models.RunHooks(bgDB, logrus.WithField("component", "hooks")) 48 | 49 | api.ListenAndServe(l) 50 | } 51 | -------------------------------------------------------------------------------- /cmd/root_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | "github.com/spf13/cobra" 6 | 7 | "github.com/netlify/gocommerce/conf" 8 | ) 9 | 10 | var configFile = "" 11 | 12 | // rootCmd will run the log streamer 13 | var rootCmd = cobra.Command{ 14 | Use: "gocommerce", 15 | Long: "A service that will validate restful transactions and send them to stripe.", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | execWithConfig(cmd, serve) 18 | }, 19 | } 20 | 21 | // RootCmd will add flags and subcommands to the different commands 22 | func RootCmd() *cobra.Command { 23 | rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "The configuration file") 24 | rootCmd.AddCommand(&serveCmd, &migrateCmd, &multiCmd, &versionCmd) 25 | return &rootCmd 26 | } 27 | 28 | func execWithConfig(cmd *cobra.Command, fn func(globalConfig *conf.GlobalConfiguration, log logrus.FieldLogger, config *conf.Configuration)) { 29 | globalConfig, log, err := conf.LoadGlobal(configFile) 30 | if err != nil { 31 | logrus.Fatalf("Failed to load configuration: %+v", err) 32 | } 33 | config, err := conf.LoadConfig(configFile) 34 | if err != nil { 35 | logrus.Fatalf("Failed to load configuration: %+v", err) 36 | } 37 | 38 | fn(globalConfig, log, config) 39 | } 40 | -------------------------------------------------------------------------------- /cmd/serve_cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/netlify/gocommerce/api" 8 | "github.com/netlify/gocommerce/conf" 9 | "github.com/netlify/gocommerce/models" 10 | "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var serveCmd = cobra.Command{ 15 | Use: "serve", 16 | Long: "Start API server", 17 | Run: func(cmd *cobra.Command, args []string) { 18 | execWithConfig(cmd, serve) 19 | }, 20 | } 21 | 22 | func serve(globalConfig *conf.GlobalConfiguration, log logrus.FieldLogger, config *conf.Configuration) { 23 | db, err := models.Connect(globalConfig, log.WithField("component", "db")) 24 | if err != nil { 25 | log.Fatalf("Error opening database: %+v", err) 26 | } 27 | defer db.Close() 28 | 29 | bgDB, err := models.Connect(globalConfig, log.WithField("component", "db").WithField("bgdb", true)) 30 | if err != nil { 31 | log.Fatalf("Error opening database: %+v", err) 32 | } 33 | defer bgDB.Close() 34 | 35 | ctx, err := api.WithInstanceConfig(context.Background(), globalConfig.SMTP, config, "") 36 | if err != nil { 37 | log.Fatalf("Error loading instance config: %+v", err) 38 | } 39 | api := api.NewAPIWithVersion(ctx, globalConfig, log, db, Version) 40 | 41 | l := fmt.Sprintf("%v:%v", globalConfig.API.Host, globalConfig.API.Port) 42 | log.Infof("GoCommerce API started on: %s", l) 43 | 44 | models.RunHooks(bgDB, log.WithField("component", "hooks")) 45 | 46 | api.ListenAndServe(l) 47 | } 48 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // Version is the SHA of the git commit from which this binary was built. 10 | var Version string 11 | 12 | var versionCmd = cobra.Command{ 13 | Run: showVersion, 14 | Use: "version", 15 | } 16 | 17 | func showVersion(cmd *cobra.Command, args []string) { 18 | fmt.Println(Version) 19 | } 20 | -------------------------------------------------------------------------------- /conf/configuration.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/joho/godotenv" 7 | "github.com/kelseyhightower/envconfig" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | // DBConfiguration holds all the database related configuration. 12 | type DBConfiguration struct { 13 | Dialect string 14 | Driver string `required:"true"` 15 | URL string `envconfig:"DATABASE_URL" required:"true"` 16 | Namespace string 17 | Automigrate bool 18 | } 19 | 20 | // JWTConfiguration holds all the JWT related configuration. 21 | type JWTConfiguration struct { 22 | Secret string `json:"secret"` 23 | AdminGroupName string `json:"admin_group_name" split_words:"true"` 24 | } 25 | 26 | type SMTPConfiguration struct { 27 | Host string `json:"host"` 28 | Port int `json:"port" default:"587"` 29 | User string `json:"user"` 30 | Pass string `json:"pass"` 31 | AdminEmail string `json:"admin_email" split_words:"true"` 32 | } 33 | 34 | // GlobalConfiguration holds all the global configuration for gocommerce 35 | type GlobalConfiguration struct { 36 | API struct { 37 | Host string 38 | Port int `envconfig:"PORT" default:"8080"` 39 | Endpoint string 40 | } 41 | DB DBConfiguration 42 | Logging LoggingConfig `envconfig:"LOG"` 43 | OperatorToken string `split_words:"true"` 44 | MultiInstanceMode bool 45 | SMTP SMTPConfiguration `json:"smtp"` 46 | } 47 | 48 | // EmailContentConfiguration holds the configuration for emails, both subjects and template URLs. 49 | type EmailContentConfiguration struct { 50 | OrderConfirmation string `json:"order_confirmation" split_words:"true"` 51 | OrderReceived string `json:"order_received" split_words:"true"` 52 | } 53 | 54 | // Configuration holds all the per-tenant configuration for gocommerce 55 | type Configuration struct { 56 | SiteURL string `json:"site_url" split_words:"true" required:"true"` 57 | JWT JWTConfiguration `json:"jwt"` 58 | 59 | SMTP SMTPConfiguration `json:"smtp"` 60 | 61 | Mailer struct { 62 | Subjects EmailContentConfiguration `json:"subjects"` 63 | Templates EmailContentConfiguration `json:"templates"` 64 | } `json:"mailer"` 65 | 66 | Payment struct { 67 | Stripe struct { 68 | Enabled bool `json:"enabled"` 69 | PublicKey string `json:"public_key" split_words:"true"` 70 | SecretKey string `json:"secret_key" split_words:"true"` 71 | } `json:"stripe"` 72 | PayPal struct { 73 | Enabled bool `json:"enabled"` 74 | ClientID string `json:"client_id" split_words:"true"` 75 | Secret string `json:"secret"` 76 | Env string `json:"env"` 77 | } `json:"paypal"` 78 | } `json:"payment"` 79 | 80 | Downloads struct { 81 | Provider string `json:"provider"` 82 | NetlifyToken string `json:"netlify_token" split_words:"true"` 83 | } `json:"downloads"` 84 | 85 | Coupons struct { 86 | URL string `json:"url"` 87 | User string `json:"user"` 88 | Password string `json:"password"` 89 | } `json:"coupons"` 90 | 91 | Webhooks struct { 92 | Order string `json:"order"` 93 | Payment string `json:"payment"` 94 | Update string `json:"update"` 95 | Refund string `json:"refund"` 96 | 97 | Secret string `json:"secret"` 98 | } `json:"webhooks"` 99 | } 100 | 101 | func (c *Configuration) SettingsURL() string { 102 | return c.SiteURL + "/gocommerce/settings.json" 103 | } 104 | 105 | func loadEnvironment(filename string) error { 106 | var err error 107 | if filename != "" { 108 | err = godotenv.Load(filename) 109 | } else { 110 | err = godotenv.Load() 111 | // handle if .env file does not exist, this is OK 112 | if os.IsNotExist(err) { 113 | return nil 114 | } 115 | } 116 | return err 117 | } 118 | 119 | // LoadGlobal will construct the core config from the file 120 | func LoadGlobal(filename string) (*GlobalConfiguration, *logrus.Entry, error) { 121 | if err := loadEnvironment(filename); err != nil { 122 | return nil, nil, err 123 | } 124 | 125 | config := new(GlobalConfiguration) 126 | if err := envconfig.Process("gocommerce", config); err != nil { 127 | return nil, nil, err 128 | } 129 | log, err := ConfigureLogging(&config.Logging) 130 | if err != nil { 131 | return nil, nil, err 132 | } 133 | return config, log, nil 134 | } 135 | 136 | // LoadConfig loads the per-instance configuration from a file 137 | func LoadConfig(filename string) (*Configuration, error) { 138 | if err := loadEnvironment(filename); err != nil { 139 | return nil, err 140 | } 141 | 142 | config := new(Configuration) 143 | if err := envconfig.Process("gocommerce", config); err != nil { 144 | return nil, err 145 | } 146 | config.ApplyDefaults() 147 | return config, nil 148 | } 149 | 150 | // ApplyDefaults sets defaults for a Configuration 151 | func (config *Configuration) ApplyDefaults() { 152 | if config.JWT.AdminGroupName == "" { 153 | config.JWT.AdminGroupName = "admin" 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /conf/logging.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type LoggingConfig struct { 11 | Level string `mapstructure:"log_level" json:"log_level"` 12 | File string `mapstructure:"log_file" json:"log_file"` 13 | DisableColors bool `mapstructure:"disable_colors" split_words:"true" json:"disable_colors"` 14 | QuoteEmptyFields bool `mapstructure:"quote_empty_fields" split_words:"true" json:"quote_empty_fields"` 15 | TSFormat string `mapstructure:"ts_format" json:"ts_format"` 16 | Fields map[string]interface{} `mapstructure:"fields" json:"fields"` 17 | UseNewLogger bool `mapstructure:"use_new_logger",split_words:"true"` 18 | } 19 | 20 | func ConfigureLogging(config *LoggingConfig) (*logrus.Entry, error) { 21 | logger := logrus.New() 22 | 23 | tsFormat := time.RFC3339Nano 24 | if config.TSFormat != "" { 25 | tsFormat = config.TSFormat 26 | } 27 | // always use the full timestamp 28 | logger.SetFormatter(&logrus.TextFormatter{ 29 | FullTimestamp: true, 30 | DisableTimestamp: false, 31 | TimestampFormat: tsFormat, 32 | DisableColors: config.DisableColors, 33 | QuoteEmptyFields: config.QuoteEmptyFields, 34 | }) 35 | 36 | // use a file if you want 37 | if config.File != "" { 38 | f, errOpen := os.OpenFile(config.File, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0664) 39 | if errOpen != nil { 40 | return nil, errOpen 41 | } 42 | logger.SetOutput(f) 43 | logger.Infof("Set output file to %s", config.File) 44 | } 45 | 46 | if config.Level != "" { 47 | level, err := logrus.ParseLevel(config.Level) 48 | if err != nil { 49 | return nil, err 50 | } 51 | logger.SetLevel(level) 52 | logger.Debug("Set log level to: " + logger.GetLevel().String()) 53 | } 54 | 55 | f := logrus.Fields{} 56 | for k, v := range config.Fields { 57 | f[k] = v 58 | } 59 | 60 | return logger.WithFields(f), nil 61 | } 62 | -------------------------------------------------------------------------------- /context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "fmt" 5 | 6 | "context" 7 | 8 | "github.com/golang-jwt/jwt/v4" 9 | "github.com/jinzhu/gorm" 10 | 11 | "github.com/netlify/gocommerce/assetstores" 12 | "github.com/netlify/gocommerce/claims" 13 | "github.com/netlify/gocommerce/conf" 14 | "github.com/netlify/gocommerce/coupons" 15 | "github.com/netlify/gocommerce/mailer" 16 | "github.com/netlify/gocommerce/models" 17 | "github.com/netlify/gocommerce/payments" 18 | ) 19 | 20 | type contextKey string 21 | 22 | func (c contextKey) String() string { 23 | return "api context key " + string(c) 24 | } 25 | 26 | const ( 27 | tokenKey = contextKey("jwt") 28 | configKey = contextKey("config") 29 | couponsKey = contextKey("coupons") 30 | requestIDKey = contextKey("request_id") 31 | adminFlagKey = contextKey("is_admin") 32 | mailerKey = contextKey("mailer") 33 | assetStoreKey = contextKey("asset_store") 34 | paymentProviderKey = contextKey("payment-provider") 35 | userIDKey = contextKey("user_id") 36 | userKey = contextKey("user") 37 | orderIDKey = contextKey("order_id") 38 | instanceIDKey = contextKey("instance_id") 39 | instanceKey = contextKey("instance") 40 | dbKey = contextKey("db") 41 | ) 42 | 43 | // WithConfig adds the tenant configuration to the context. 44 | func WithConfig(ctx context.Context, config *conf.Configuration) context.Context { 45 | return context.WithValue(ctx, configKey, config) 46 | } 47 | 48 | // GetConfig reads the tenant configuration from the context. 49 | func GetConfig(ctx context.Context) *conf.Configuration { 50 | obj := ctx.Value(configKey) 51 | if obj == nil { 52 | return nil 53 | } 54 | 55 | return obj.(*conf.Configuration) 56 | } 57 | 58 | // WithCoupons adds the coupon cache to the context based on the site URL. 59 | func WithCoupons(ctx context.Context, config *conf.Configuration) (context.Context, error) { 60 | cache, err := coupons.NewCouponCacheFromURL(config) 61 | if err != nil { 62 | return nil, err 63 | } 64 | return context.WithValue(ctx, couponsKey, cache), nil 65 | } 66 | 67 | // GetCoupons reads the coupon cache from the context. 68 | func GetCoupons(ctx context.Context) coupons.Cache { 69 | obj := ctx.Value(couponsKey) 70 | if obj == nil { 71 | return nil 72 | } 73 | 74 | return obj.(coupons.Cache) 75 | } 76 | 77 | // WithToken adds the JWT token to the context. 78 | func WithToken(ctx context.Context, token *jwt.Token) context.Context { 79 | return context.WithValue(ctx, tokenKey, token) 80 | } 81 | 82 | // GetToken reads the JWT token from the context. 83 | func GetToken(ctx context.Context) *jwt.Token { 84 | obj := ctx.Value(tokenKey) 85 | if obj == nil { 86 | return nil 87 | } 88 | 89 | return obj.(*jwt.Token) 90 | } 91 | 92 | // WithRequestID adds the provided request ID to the context. 93 | func WithRequestID(ctx context.Context, id string) context.Context { 94 | return context.WithValue(ctx, requestIDKey, id) 95 | } 96 | 97 | // GetRequestID reads the request ID from the context. 98 | func GetRequestID(ctx context.Context) string { 99 | obj := ctx.Value(requestIDKey) 100 | if obj == nil { 101 | return "" 102 | } 103 | 104 | return obj.(string) 105 | } 106 | 107 | // WithMailer adds the mailer to the context. 108 | func WithMailer(ctx context.Context, mailer mailer.Mailer) context.Context { 109 | return context.WithValue(ctx, mailerKey, mailer) 110 | } 111 | 112 | // GetMailer reads the mailer from the context. 113 | func GetMailer(ctx context.Context) mailer.Mailer { 114 | obj := ctx.Value(mailerKey) 115 | if obj == nil { 116 | return nil 117 | } 118 | return obj.(mailer.Mailer) 119 | } 120 | 121 | // WithAssetStore adds the asset store to the context. 122 | func WithAssetStore(ctx context.Context, store assetstores.Store) context.Context { 123 | return context.WithValue(ctx, assetStoreKey, store) 124 | } 125 | 126 | // GetAssetStore reads the asset store from the context. 127 | func GetAssetStore(ctx context.Context) assetstores.Store { 128 | obj := ctx.Value(assetStoreKey) 129 | if obj == nil { 130 | return nil 131 | } 132 | return obj.(assetstores.Store) 133 | } 134 | 135 | // WithPaymentProviders adds the payment providers to the context. 136 | func WithPaymentProviders(ctx context.Context, provs map[string]payments.Provider) context.Context { 137 | return context.WithValue(ctx, paymentProviderKey, provs) 138 | } 139 | 140 | // GetPaymentProviders reads the payment providers from the context 141 | func GetPaymentProviders(ctx context.Context) map[string]payments.Provider { 142 | provs, _ := ctx.Value(paymentProviderKey).(map[string]payments.Provider) 143 | return provs 144 | } 145 | 146 | // GetClaims reads the claims contained within the JWT token stored in the context. 147 | func GetClaims(ctx context.Context) *claims.JWTClaims { 148 | token := GetToken(ctx) 149 | if token == nil { 150 | return nil 151 | } 152 | return token.Claims.(*claims.JWTClaims) 153 | } 154 | 155 | // GetClaimsAsMap reads the claims contained with the JWT token stored in the 156 | // context, as a map. 157 | func GetClaimsAsMap(ctx context.Context) map[string]interface{} { 158 | token := GetToken(ctx) 159 | if token == nil { 160 | return nil 161 | } 162 | config := GetConfig(ctx) 163 | if config == nil { 164 | return nil 165 | } 166 | claims := jwt.MapClaims{} 167 | _, err := jwt.ParseWithClaims(token.Raw, &claims, func(token *jwt.Token) (interface{}, error) { 168 | if token.Header["alg"] != jwt.SigningMethodHS256.Name { 169 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) 170 | } 171 | return []byte(config.JWT.Secret), nil 172 | }) 173 | if err != nil { 174 | return nil 175 | } 176 | 177 | return map[string]interface{}(claims) 178 | } 179 | 180 | // WithAdminFlag adds a flag indicating admin status to the context. 181 | func WithAdminFlag(ctx context.Context, isAdmin bool) context.Context { 182 | return context.WithValue(ctx, adminFlagKey, isAdmin) 183 | } 184 | 185 | // IsAdmin reads the admin flag from the context. 186 | func IsAdmin(ctx context.Context) bool { 187 | obj := ctx.Value(adminFlagKey) 188 | if obj == nil { 189 | return false 190 | } 191 | return obj.(bool) 192 | } 193 | 194 | // GetUserID reads the user ID from the context. 195 | func GetUserID(ctx context.Context) string { 196 | id, _ := ctx.Value(userIDKey).(string) 197 | return id 198 | } 199 | 200 | // WithUserID adds the user ID to the context. 201 | func WithUserID(ctx context.Context, userID string) context.Context { 202 | return context.WithValue(ctx, userIDKey, userID) 203 | } 204 | 205 | // GetUser reads the user from the context. 206 | func GetUser(ctx context.Context) *models.User { 207 | u := ctx.Value(userKey) 208 | if u == nil { 209 | return nil 210 | } 211 | return u.(*models.User) 212 | } 213 | 214 | // WithUser adds the user to the context. 215 | func WithUser(ctx context.Context, user *models.User) context.Context { 216 | return context.WithValue(ctx, userKey, user) 217 | } 218 | 219 | // GetOrderID reads the order ID from the context. 220 | func GetOrderID(ctx context.Context) string { 221 | id, _ := ctx.Value(orderIDKey).(string) 222 | return id 223 | } 224 | 225 | // WithOrderID adds the order ID to the context. 226 | func WithOrderID(ctx context.Context, orderID string) context.Context { 227 | return context.WithValue(ctx, orderIDKey, orderID) 228 | } 229 | 230 | // WithInstanceID adds the instance id to the context. 231 | func WithInstanceID(ctx context.Context, id string) context.Context { 232 | return context.WithValue(ctx, instanceIDKey, id) 233 | } 234 | 235 | // GetInstanceID reads the instance id from the context. 236 | func GetInstanceID(ctx context.Context) string { 237 | obj := ctx.Value(instanceIDKey) 238 | if obj == nil { 239 | return "" 240 | } 241 | return obj.(string) 242 | } 243 | 244 | // WithInstance adds the instance id to the context. 245 | func WithInstance(ctx context.Context, i *models.Instance) context.Context { 246 | return context.WithValue(ctx, instanceKey, i) 247 | } 248 | 249 | // GetInstance reads the instance id from the context. 250 | func GetInstance(ctx context.Context) *models.Instance { 251 | obj := ctx.Value(instanceKey) 252 | if obj == nil { 253 | return nil 254 | } 255 | return obj.(*models.Instance) 256 | } 257 | 258 | // GetDB reads the database from the context. 259 | func GetDB(ctx context.Context) *gorm.DB { 260 | obj := ctx.Value(dbKey) 261 | if obj == nil { 262 | return nil 263 | } 264 | return obj.(*gorm.DB) 265 | } 266 | 267 | // WithDB adds the database to the context. 268 | func WithDB(ctx context.Context, db *gorm.DB) context.Context { 269 | return context.WithValue(ctx, dbKey, db) 270 | } 271 | -------------------------------------------------------------------------------- /coupons/coupons.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "sync" 9 | "time" 10 | 11 | "github.com/netlify/gocommerce/conf" 12 | "github.com/netlify/gocommerce/models" 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | const cacheTime = 1 * time.Minute 17 | 18 | // Cache is an interface for how to lookup a coupon based upon the code. 19 | type Cache interface { 20 | Lookup(string) (*models.Coupon, error) 21 | List() (map[string]*models.Coupon, error) 22 | } 23 | 24 | // CouponNotFound is an error when a coupon could not be found. 25 | type CouponNotFound struct{} 26 | 27 | func (CouponNotFound) Error() string { 28 | return "Coupon not found" 29 | } 30 | 31 | type couponsResponse struct { 32 | Coupons map[string]*models.Coupon `json:"coupons"` 33 | } 34 | 35 | type couponCacheFromURL struct { 36 | url string 37 | user string 38 | password string 39 | lastFetch time.Time 40 | coupons map[string]*models.Coupon 41 | mutex sync.Mutex 42 | client *http.Client 43 | } 44 | 45 | // NewCouponCacheFromURL creates a coupon cache using the provided configuration. 46 | func NewCouponCacheFromURL(config *conf.Configuration) (Cache, error) { 47 | if config.Coupons.URL == "" { 48 | return nil, nil 49 | } 50 | 51 | url, err := url.Parse(config.Coupons.URL) 52 | if err != nil { 53 | return nil, errors.Wrapf(err, "Failed to parse Coupons URL") 54 | } 55 | 56 | if !url.IsAbs() { 57 | siteURL, err := url.Parse(config.SiteURL) 58 | if err != nil { 59 | return nil, errors.Wrapf(err, "Failed to parse Site URL") 60 | } 61 | url.Scheme = siteURL.Scheme 62 | url.Host = siteURL.Host 63 | url.User = siteURL.User 64 | } 65 | 66 | return &couponCacheFromURL{ 67 | url: url.String(), 68 | user: config.Coupons.User, 69 | password: config.Coupons.Password, 70 | coupons: map[string]*models.Coupon{}, 71 | client: &http.Client{}, 72 | lastFetch: time.Unix(0, 0), 73 | }, nil 74 | } 75 | 76 | func (c *couponCacheFromURL) load() error { 77 | req, err := http.NewRequest(http.MethodGet, c.url, nil) 78 | if err != nil { 79 | return err 80 | } 81 | if c.user != "" { 82 | req.SetBasicAuth(c.user, c.password) 83 | } 84 | resp, err := c.client.Do(req) 85 | if err != nil { 86 | return errors.Wrap(err, "Failed to make request for coupon information") 87 | } 88 | 89 | if resp.StatusCode != http.StatusOK { 90 | return fmt.Errorf("Coupon URL returned %v", resp.StatusCode) 91 | } 92 | 93 | couponsResponse := &couponsResponse{} 94 | if resp.Body != nil && resp.Body != http.NoBody { 95 | defer resp.Body.Close() 96 | decoder := json.NewDecoder(resp.Body) 97 | if err := decoder.Decode(couponsResponse); err != nil { 98 | return errors.Wrap(err, "Failed to parse response.") 99 | } 100 | 101 | for key, coupon := range couponsResponse.Coupons { 102 | if coupon.Code == "" { 103 | coupon.Code = key 104 | } 105 | } 106 | } 107 | 108 | c.mutex.Lock() 109 | c.coupons = couponsResponse.Coupons 110 | c.lastFetch = time.Now() 111 | c.mutex.Unlock() 112 | 113 | return nil 114 | } 115 | 116 | func (c *couponCacheFromURL) Lookup(code string) (*models.Coupon, error) { 117 | if time.Now().After(c.lastFetch.Add(cacheTime)) { 118 | if err := c.load(); err != nil { 119 | return nil, err 120 | } 121 | } 122 | 123 | coupon, ok := c.coupons[code] 124 | if ok { 125 | return coupon, nil 126 | } 127 | return nil, &CouponNotFound{} 128 | } 129 | 130 | func (c *couponCacheFromURL) List() (map[string]*models.Coupon, error) { 131 | if time.Now().After(c.lastFetch.Add(cacheTime)) { 132 | if err := c.load(); err != nil { 133 | return nil, err 134 | } 135 | } 136 | 137 | return c.coupons, nil 138 | } 139 | -------------------------------------------------------------------------------- /coupons/coupons_test.go: -------------------------------------------------------------------------------- 1 | package coupons 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | "time" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/netlify/gocommerce/conf" 14 | ) 15 | 16 | func TestRelativeURL(t *testing.T) { 17 | var callCount int 18 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 19 | callCount++ 20 | assert.Equal(t, "/this/is/where/the/coupons/are", r.URL.Path) 21 | user, pass, ok := r.BasicAuth() 22 | assert.True(t, ok) 23 | assert.Equal(t, "kitten", user) 24 | assert.Equal(t, "catnip4life", pass) 25 | 26 | rsp := map[string]interface{}{ 27 | "coupons": map[string]interface{}{ 28 | "meow": map[string]interface{}{ 29 | "code": "catnip", 30 | }, 31 | "magic": map[string]interface{}{}, 32 | }, 33 | } 34 | data, err := json.Marshal(&rsp) 35 | require.NoError(t, err) 36 | w.Write(data) 37 | })) 38 | 39 | defer svr.Close() 40 | c := &conf.Configuration{ 41 | SiteURL: svr.URL, 42 | } 43 | c.Coupons.URL = "this/is/where/the/coupons/are" 44 | c.Coupons.User = "kitten" 45 | c.Coupons.Password = "catnip4life" 46 | 47 | cacheFace, err := NewCouponCacheFromURL(c) 48 | require.NoError(t, err) 49 | require.NotNil(t, cacheFace) 50 | 51 | cache, ok := cacheFace.(*couponCacheFromURL) 52 | require.True(t, ok) 53 | 54 | assert.Equal(t, svr.URL+"/this/is/where/the/coupons/are", cache.url) 55 | 56 | coupons, err := cache.List() 57 | require.NoError(t, err) 58 | 59 | require.Equal(t, 2, len(coupons)) 60 | meow := coupons["meow"] 61 | assert.Equal(t, "catnip", meow.Code) 62 | assert.Equal(t, callCount, 1) 63 | magic := coupons["magic"] 64 | assert.Equal(t, "magic", magic.Code) 65 | 66 | // make sure this is cached 67 | _, err = cache.List() 68 | require.NoError(t, err) 69 | assert.Equal(t, callCount, 1) 70 | } 71 | 72 | func TestExplicitLookup(t *testing.T) { 73 | var callCount int 74 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 75 | callCount++ 76 | assert.Equal(t, "/this/is/where/the/coupons/are", r.URL.Path) 77 | rsp := map[string]interface{}{ 78 | "coupons": map[string]interface{}{ 79 | "meow": map[string]interface{}{}, 80 | }, 81 | } 82 | data, err := json.Marshal(&rsp) 83 | require.NoError(t, err) 84 | w.Write(data) 85 | })) 86 | 87 | defer svr.Close() 88 | c := &conf.Configuration{ 89 | SiteURL: svr.URL, 90 | } 91 | c.Coupons.URL = "this/is/where/the/coupons/are" 92 | cache := newCache(t, c) 93 | assert.Equal(t, svr.URL+"/this/is/where/the/coupons/are", cache.url) 94 | 95 | coupon, err := cache.Lookup("meow") 96 | require.NoError(t, err) 97 | assert.Equal(t, 1, callCount) 98 | 99 | assert.Equal(t, "meow", coupon.Code) 100 | 101 | coupon, err = cache.Lookup("dne") 102 | assert.Error(t, err) 103 | assert.IsType(t, new(CouponNotFound), err) 104 | assert.Nil(t, coupon) 105 | assert.Equal(t, 1, callCount) 106 | } 107 | 108 | func TestCacheExpiration(t *testing.T) { 109 | var callCount int 110 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 111 | callCount++ 112 | assert.Equal(t, "/this/is/where/the/coupons/are", r.URL.Path) 113 | w.WriteHeader(http.StatusOK) 114 | })) 115 | defer svr.Close() 116 | 117 | c := &conf.Configuration{ 118 | SiteURL: "this is garbage", 119 | } 120 | // note that this is an absolute path, it is what is used 121 | c.Coupons.URL = svr.URL + "/this/is/where/the/coupons/are" 122 | cache := newCache(t, c) 123 | assert.Equal(t, svr.URL+"/this/is/where/the/coupons/are", cache.url) 124 | 125 | coupon, err := cache.Lookup("meow") 126 | assert.Error(t, err) 127 | assert.IsType(t, new(CouponNotFound), err, err.Error()) 128 | assert.Nil(t, coupon) 129 | assert.Equal(t, 1, callCount) 130 | 131 | // pretend we made this request a _while_ ago 132 | cache.lastFetch = time.Now().Add(-2 * cacheTime) 133 | 134 | coupon, err = cache.Lookup("meow") 135 | assert.Error(t, err) 136 | assert.IsType(t, new(CouponNotFound), err, err.Error()) 137 | assert.Nil(t, coupon) 138 | assert.Equal(t, 2, callCount) 139 | } 140 | 141 | func TestMalformedResponse(t *testing.T) { 142 | var callCount int 143 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 | callCount++ 145 | w.WriteHeader(http.StatusOK) 146 | w.Write([]byte("this is not json")) 147 | })) 148 | defer svr.Close() 149 | 150 | c := &conf.Configuration{ 151 | SiteURL: svr.URL, 152 | } 153 | c.Coupons.URL = "/this/is/where/the/coupons/are" 154 | cache := newCache(t, c) 155 | assert.Equal(t, svr.URL+"/this/is/where/the/coupons/are", cache.url) 156 | 157 | coupon, err := cache.Lookup("meow") 158 | assert.Error(t, err) 159 | assert.Nil(t, coupon) 160 | assert.Equal(t, 1, callCount) 161 | } 162 | 163 | func newCache(t *testing.T, c *conf.Configuration) *couponCacheFromURL { 164 | cacheFace, err := NewCouponCacheFromURL(c) 165 | require.NoError(t, err) 166 | require.NotNil(t, cacheFace) 167 | cache, ok := cacheFace.(*couponCacheFromURL) 168 | require.True(t, ok) 169 | return cache 170 | } 171 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.5" 2 | 3 | networks: 4 | db: 5 | 6 | services: 7 | api: 8 | image: netlify/gocommerce 9 | build: . 10 | env_file: .env 11 | environment: 12 | PORT: 8080 13 | GOCOMMERCE_DB_DRIVER: mysql 14 | GOCOMMERCE_DB_AUTOMIGRATE: 1 15 | GOCOMMERCE_DB_DATABASE_URL: "gocommerce:gocommerce@tcp(db)/gocommerce" 16 | GOCOMMERCE_DB_NAMESPACE: dev 17 | GOCOMMERCE_LOG_LEVEL: debug 18 | ports: 19 | - 8080:8080 20 | networks: 21 | - db 22 | depends_on: 23 | - db 24 | 25 | db: 26 | image: mysql:5.7 27 | environment: 28 | MYSQL_RANDOM_ROOT_PASSWORD: "yes" 29 | MYSQL_DATABASE: gocommerce 30 | MYSQL_USER: gocommerce 31 | MYSQL_PASSWORD: gocommerce 32 | volumes: 33 | - db_data:/var/lib/mysql 34 | networks: 35 | - db 36 | 37 | volumes: 38 | db_data: 39 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | GOCOMMERCE_SITE_URL=https://my-jam-store.example.com 2 | GOCOMMERCE_JWT_SECRET="CHANGE-THIS! VERY IMPORTANT!" 3 | GOCOMMERCE_DB_DRIVER=sqlite3 4 | GOCOMMERCE_DB_AUTOMIGRATE=true 5 | DATABASE_URL=gorm.db 6 | GOCOMMERCE_API_HOST=localhost 7 | PORT=9111 8 | GOCOMMERCE_MAILER_HOST=smtp.mandrillapp.com 9 | GOCOMMERCE_MAILER_PORT=587 10 | GOCOMMERCE_MAILER_USER=test@example.com 11 | GOCOMMERCE_MAILER_PASS=super-secret-password 12 | GOCOMMERCE_MAILER_SUBJECTS_ORDER_CONFIRMATION="Thank you for your order!" 13 | GOCOMMERCE_MAILER_SUBJECTS_ORDER_RECEIVED="A new order has been placed" 14 | GOCOMMERCE_PAYMENT_STRIPE_ENABLED=true 15 | GOCOMMERCE_PAYMENT_STRIPE_PUBLIC_KEY=stripe_public_key 16 | GOCOMMERCE_PAYMENT_STRIPE_SECRET_KEY=stripe_secret_key 17 | GOCOMMERCE_PAYMENT_PAYPAL_ENABLED=false 18 | GOCOMMERCE_PAYMENT_PAYPAL_CLIENT_ID=client-id 19 | GOCOMMERCE_PAYMENT_PAYPAL_SECRET=client-secret 20 | GOCOMMERCE_PAYMENT_PAYPAL_ENV=sandbox 21 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/netlify/gocommerce 2 | 3 | require ( 4 | github.com/GoogleCloudPlatform/cloudsql-proxy v1.27.1 5 | github.com/PuerkitoBio/goquery v1.1.0 6 | github.com/go-chi/chi v4.0.2+incompatible 7 | github.com/go-sql-driver/mysql v1.6.0 8 | github.com/golang-jwt/jwt/v4 v4.2.0 9 | github.com/jinzhu/gorm v1.9.10 10 | github.com/joho/godotenv v1.3.0 11 | github.com/kelseyhightower/envconfig v1.4.0 12 | github.com/lib/pq v1.10.4 13 | github.com/mattes/vat v0.0.0-20160607175015-805d21ad0739 14 | github.com/mattn/go-sqlite3 v1.10.0 15 | github.com/mitchellh/mapstructure v1.1.2 16 | github.com/netlify/PayPal-Go-SDK v0.0.0-20180614154051-732c3d08bf8a 17 | github.com/netlify/mailme v1.1.1 18 | github.com/pariz/gountries v0.0.0-20171019111738-adb00f6513a3 19 | github.com/pborman/uuid v0.0.0-20160209185913-a97ce2ca70fa 20 | github.com/pkg/errors v0.8.1 21 | github.com/rs/cors v1.6.0 22 | github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 23 | github.com/sirupsen/logrus v1.8.1 24 | github.com/spf13/cobra v0.0.4-0.20190321000552-67fc4837d267 25 | github.com/stretchr/testify v1.7.0 26 | github.com/stripe/stripe-go v62.9.0+incompatible 27 | ) 28 | 29 | require ( 30 | cloud.google.com/go v0.99.0 // indirect 31 | github.com/andybalholm/cascadia v0.0.0-20161224141413-349dd0209470 // indirect 32 | github.com/davecgh/go-spew v1.1.1 // indirect 33 | github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect 34 | github.com/golang/protobuf v1.5.2 // indirect 35 | github.com/googleapis/gax-go/v2 v2.1.1 // indirect 36 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 37 | github.com/jinzhu/inflection v1.0.0 // indirect 38 | github.com/logpacker/PayPal-Go-SDK v2.0.5+incompatible // indirect 39 | github.com/netlify/netlify-commons v0.32.0 // indirect 40 | github.com/pmezard/go-difflib v1.0.0 // indirect 41 | github.com/spf13/pflag v1.0.3 // indirect 42 | go.opencensus.io v0.23.0 // indirect 43 | go.uber.org/atomic v1.7.0 // indirect 44 | go.uber.org/multierr v1.6.0 // indirect 45 | go.uber.org/zap v1.19.1 // indirect 46 | golang.org/x/net v0.0.0-20211020060615-d418f374d309 // indirect 47 | golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect 48 | golang.org/x/sys v0.0.0-20211209171907-798191bca915 // indirect 49 | golang.org/x/text v0.3.6 // indirect 50 | golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect 51 | google.golang.org/api v0.61.0 // indirect 52 | google.golang.org/appengine v1.6.7 // indirect 53 | google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0 // indirect 54 | google.golang.org/grpc v1.40.0 // indirect 55 | google.golang.org/protobuf v1.27.1 // indirect 56 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 57 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 58 | gopkg.in/gomail.v2 v2.0.0-20150902115704-41f357289737 // indirect 59 | gopkg.in/yaml.v2 v2.2.8 // indirect 60 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 61 | ) 62 | 63 | go 1.17 64 | -------------------------------------------------------------------------------- /mailer/mailer.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | "github.com/netlify/gocommerce/conf" 9 | "github.com/netlify/gocommerce/models" 10 | "github.com/netlify/mailme" 11 | "github.com/sirupsen/logrus" 12 | ) 13 | 14 | // Mailer will send mail and use templates from the site for easy mail styling 15 | type Mailer interface { 16 | OrderConfirmationMail(transaction *models.Transaction) error 17 | OrderReceivedMail(transaction *models.Transaction) error 18 | OrderConfirmationMailBody(transaction *models.Transaction, templateURL string) (string, error) 19 | } 20 | 21 | type mailer struct { 22 | Config *conf.Configuration 23 | TemplateMailer *mailme.Mailer 24 | } 25 | 26 | // MailSubjects holds the subject lines for the emails 27 | type MailSubjects struct { 28 | OrderConfirmationMail string 29 | } 30 | 31 | // NewMailer returns a new authlify mailer 32 | func NewMailer(smtp conf.SMTPConfiguration, instanceConfig *conf.Configuration) Mailer { 33 | if smtp.Host == "" && instanceConfig.SMTP.Host == "" { 34 | return newNoopMailer() 35 | } 36 | 37 | smtpHost := instanceConfig.SMTP.Host 38 | if smtpHost == "" { 39 | smtpHost = smtp.Host 40 | } 41 | smtpPort := instanceConfig.SMTP.Port 42 | if smtpPort == 0 { 43 | smtpPort = smtp.Port 44 | } 45 | smtpUser := instanceConfig.SMTP.User 46 | if smtpUser == "" { 47 | smtpUser = smtp.User 48 | } 49 | smtpPass := instanceConfig.SMTP.Pass 50 | if smtpPass == "" { 51 | smtpPass = smtp.Pass 52 | } 53 | smtpAdminEmail := instanceConfig.SMTP.AdminEmail 54 | if smtpAdminEmail == "" { 55 | smtpAdminEmail = smtp.AdminEmail 56 | } 57 | 58 | return &mailer{ 59 | Config: instanceConfig, 60 | TemplateMailer: &mailme.Mailer{ 61 | Host: smtpHost, 62 | Port: smtpPort, 63 | User: smtpUser, 64 | Pass: smtpPass, 65 | From: smtpAdminEmail, 66 | BaseURL: instanceConfig.SiteURL, 67 | FuncMap: map[string]interface{}{ 68 | "dateFormat": dateFormat, 69 | "price": price, 70 | "hasProductType": hasProductType, 71 | }, 72 | Logger: logrus.New(), 73 | }, 74 | } 75 | } 76 | 77 | func dateFormat(layout string, date time.Time) string { 78 | return date.Format(layout) 79 | } 80 | 81 | func price(amount uint64, currency string) string { 82 | switch currency { 83 | case "USD": 84 | return fmt.Sprintf("$%.2f", float64(amount)/100) 85 | case "EUR": 86 | return fmt.Sprintf("%.2f€", float64(amount)/100) 87 | default: 88 | return fmt.Sprintf("%.2f %v", float64(amount)/100, currency) 89 | } 90 | } 91 | 92 | func hasProductType(order *models.Order, productType string) bool { 93 | for _, item := range order.LineItems { 94 | if item.Type == productType { 95 | return true 96 | } 97 | } 98 | return false 99 | } 100 | 101 | const defaultConfirmationTemplate = `

Thank you for your order!

102 | 103 | 108 | 109 |

Total amount: {{ .Order.Total }}

110 | ` 111 | 112 | // OrderConfirmationMail sends an order confirmation to the user 113 | func (m *mailer) OrderConfirmationMail(transaction *models.Transaction) error { 114 | log.Printf("Sending order confirmation to %v with template %v", transaction.Order.Email, m.Config.Mailer.Templates.OrderConfirmation) 115 | return m.TemplateMailer.Mail( 116 | transaction.Order.Email, 117 | withDefault(m.Config.Mailer.Subjects.OrderConfirmation, "Order Confirmation"), 118 | m.Config.Mailer.Templates.OrderConfirmation, 119 | defaultConfirmationTemplate, 120 | map[string]interface{}{ 121 | "SiteURL": m.Config.SiteURL, 122 | "Order": transaction.Order, 123 | "Transaction": transaction, 124 | }, 125 | ) 126 | } 127 | 128 | const defaultReceivedTemplate = `

Order Received From {{ .Order.Email }}

129 | 130 | 135 | 136 |

Total amount: {{ .Order.Total }}

137 | ` 138 | 139 | // OrderReceivedMail sends a notification to the shop admin 140 | func (m *mailer) OrderReceivedMail(transaction *models.Transaction) error { 141 | return m.TemplateMailer.Mail( 142 | m.TemplateMailer.From, 143 | withDefault(m.Config.Mailer.Subjects.OrderReceived, "Order Received From {{ .Order.Email }}"), 144 | m.Config.Mailer.Templates.OrderReceived, 145 | defaultReceivedTemplate, 146 | map[string]interface{}{ 147 | "SiteURL": m.Config.SiteURL, 148 | "Order": transaction.Order, 149 | "Transaction": transaction, 150 | }, 151 | ) 152 | } 153 | 154 | func (m *mailer) OrderConfirmationMailBody(transaction *models.Transaction, templateURL string) (string, error) { 155 | if templateURL == "" { 156 | templateURL = m.Config.Mailer.Templates.OrderConfirmation 157 | } 158 | 159 | return m.TemplateMailer.MailBody(templateURL, defaultReceivedTemplate, map[string]interface{}{ 160 | "SiteURL": m.Config.SiteURL, 161 | "Order": transaction.Order, 162 | "Transaction": transaction, 163 | }) 164 | } 165 | 166 | func withDefault(value string, defaultValue string) string { 167 | if value == "" { 168 | return defaultValue 169 | } 170 | return value 171 | } 172 | -------------------------------------------------------------------------------- /mailer/mailer_test.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/netlify/gocommerce/conf" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNoopMailer(t *testing.T) { 11 | smtp := conf.SMTPConfiguration{} 12 | conf := &conf.Configuration{} 13 | m := NewMailer(smtp, conf) 14 | assert.IsType(t, &noopMailer{}, m) 15 | } 16 | 17 | func TestTemplateMailer(t *testing.T) { 18 | smtp := conf.SMTPConfiguration{ 19 | Host: "localhost", 20 | Port: 25, 21 | } 22 | conf := &conf.Configuration{} 23 | conf.SMTP.AdminEmail = "test@example.com" 24 | m := NewMailer(smtp, conf) 25 | assert.IsType(t, &mailer{}, m) 26 | } 27 | -------------------------------------------------------------------------------- /mailer/noop.go: -------------------------------------------------------------------------------- 1 | package mailer 2 | 3 | import "github.com/netlify/gocommerce/models" 4 | 5 | type noopMailer struct{} 6 | 7 | func newNoopMailer() Mailer { 8 | return &noopMailer{} 9 | } 10 | 11 | func (m *noopMailer) OrderConfirmationMail(transaction *models.Transaction) error { 12 | return nil 13 | } 14 | func (m *noopMailer) OrderReceivedMail(transaction *models.Transaction) error { 15 | return nil 16 | } 17 | 18 | func (m *noopMailer) OrderConfirmationMailBody(transaction *models.Transaction, templateURL string) (string, error) { 19 | return "Order Confirmed", nil 20 | } 21 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/netlify/gocommerce/cmd" 8 | ) 9 | 10 | func main() { 11 | if err := cmd.RootCmd().Execute(); err != nil { 12 | fmt.Fprintf(os.Stderr, "Failed to run command: %v\n", err) 13 | os.Exit(1) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /models/address.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // AddressRequest is the raw address data 10 | type AddressRequest struct { 11 | Name string `json:"name"` 12 | 13 | Company string `json:"company"` 14 | Address1 string `json:"address1"` 15 | Address2 string `json:"address2"` 16 | City string `json:"city"` 17 | Country string `json:"country"` 18 | State string `json:"state"` 19 | Zip string `json:"zip"` 20 | 21 | // deprecated 22 | FirstName string `json:"first_name,omitempty"` 23 | LastName string `json:"last_name,omitempty"` 24 | } 25 | 26 | // Address is a stored address, reusable with an ID. 27 | type Address struct { 28 | AddressRequest 29 | 30 | ID string `json:"id"` 31 | 32 | User *User `json:"-"` 33 | UserID string `json:"-"` 34 | 35 | CreatedAt time.Time `json:"created_at"` 36 | DeletedAt *time.Time `json:"deleted_at"` 37 | } 38 | 39 | // TableName returns the table name used for the Address model 40 | func (Address) TableName() string { 41 | return tableName("addresses") 42 | } 43 | 44 | // Validate validates the AddressRequest model 45 | func (a AddressRequest) Validate() error { 46 | a.combineNames() 47 | required := map[string]string{ 48 | "name": a.Name, 49 | "address": a.Address1, 50 | "country": a.Country, 51 | "city": a.City, 52 | "zip": a.Zip, 53 | } 54 | 55 | missing := []string{} 56 | for name, val := range required { 57 | if val == "" { 58 | missing = append(missing, name) 59 | } 60 | } 61 | 62 | if len(missing) > 0 { 63 | return fmt.Errorf("Required field missing: " + strings.Join(missing, ",")) 64 | } 65 | 66 | return nil 67 | } 68 | 69 | // BeforeSave database callback. 70 | func (a *AddressRequest) BeforeSave() (err error) { 71 | a.combineNames() 72 | return err 73 | } 74 | 75 | // AfterFind database callback. 76 | func (a *AddressRequest) AfterFind() (err error) { 77 | a.combineNames() 78 | return nil 79 | } 80 | 81 | func (a *AddressRequest) combineNames() { 82 | if a.Name == "" { 83 | a.Name = strings.TrimSpace(strings.Join([]string{a.FirstName, a.LastName}, " ")) 84 | a.FirstName = "" 85 | a.LastName = "" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /models/connection.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | // this is where we do the connections 5 | _ "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/mysql" 6 | _ "github.com/GoogleCloudPlatform/cloudsql-proxy/proxy/dialers/postgres" 7 | _ "github.com/go-sql-driver/mysql" 8 | "github.com/jinzhu/gorm" 9 | _ "github.com/lib/pq" 10 | _ "github.com/mattn/go-sqlite3" 11 | "github.com/netlify/gocommerce/conf" 12 | "github.com/pkg/errors" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | // Namespace puts all tables names under a common 17 | // namespace. This is useful if you want to use 18 | // the same database for several services and don't 19 | // want table names to collide. 20 | var Namespace string 21 | 22 | // Connect will connect to that storage engine 23 | func Connect(config *conf.GlobalConfiguration, log logrus.FieldLogger) (*gorm.DB, error) { 24 | if config.DB.Namespace != "" { 25 | Namespace = config.DB.Namespace 26 | } 27 | 28 | if config.DB.Dialect == "" { 29 | config.DB.Dialect = config.DB.Driver 30 | } 31 | db, err := gorm.Open(config.DB.Dialect, config.DB.Driver, config.DB.URL) 32 | if err != nil { 33 | return nil, errors.Wrap(err, "opening database connection") 34 | } 35 | 36 | db.SetLogger(NewDBLogger(log)) 37 | db.LogMode(true) 38 | 39 | err = db.DB().Ping() 40 | if err != nil { 41 | return nil, errors.Wrap(err, "checking database connection") 42 | } 43 | 44 | if config.DB.Automigrate { 45 | migDB := db.New() 46 | migDB.SetLogger(NewDBLogger(log.WithField("task", "migration"))) 47 | if err := AutoMigrate(migDB); err != nil { 48 | return nil, errors.Wrap(err, "migrating tables") 49 | } 50 | } 51 | 52 | return db, nil 53 | } 54 | 55 | func tableName(defaultName string) string { 56 | if Namespace != "" { 57 | return Namespace + "_" + defaultName 58 | } 59 | return defaultName 60 | } 61 | 62 | // AutoMigrate runs the gorm automigration for all models 63 | func AutoMigrate(db *gorm.DB) error { 64 | db = db.AutoMigrate(Address{}, 65 | LineItem{}, 66 | AddonItem{}, 67 | PriceItem{}, 68 | Hook{}, 69 | Download{}, 70 | Order{}, 71 | OrderNote{}, 72 | Transaction{}, 73 | User{}, 74 | Event{}, 75 | Instance{}, 76 | InvoiceNumber{}, 77 | ) 78 | return db.Error 79 | } 80 | -------------------------------------------------------------------------------- /models/connection_logger.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/sirupsen/logrus" 10 | ) 11 | 12 | type DBLogger struct { 13 | logrus.FieldLogger 14 | } 15 | 16 | func NewDBLogger(log logrus.FieldLogger) *DBLogger { 17 | return &DBLogger{log} 18 | } 19 | 20 | func (dbl *DBLogger) Print(params ...interface{}) { 21 | if len(params) <= 1 { 22 | return 23 | } 24 | 25 | level := params[0] 26 | log := dbl.WithField("gorm_level", level).WithField("db_src", params[1]) 27 | 28 | if level != "sql" { 29 | log.Debug(params[2:]...) 30 | return 31 | } 32 | 33 | dur := params[2].(time.Duration) 34 | sql := params[3].(string) 35 | sqlValues := params[4].([]interface{}) 36 | rows := params[5].(int64) 37 | 38 | values := "" 39 | if valuesJSON, err := json.Marshal(sqlValues); err == nil { 40 | values = string(valuesJSON) 41 | } else { 42 | values = fmt.Sprintf("%+v", sqlValues) 43 | } 44 | 45 | log. 46 | WithField("dur_ns", dur.Nanoseconds()). 47 | WithField("dur", dur). 48 | WithField("sql", strings.ReplaceAll(sql, `"`, `'`)). 49 | WithField("values", strings.ReplaceAll(values, `"`, `'`)). 50 | WithField("rows", rows). 51 | Debug("sql query") 52 | } 53 | -------------------------------------------------------------------------------- /models/coupon.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "math" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | // FixedAmount represents an amount and currency pair 10 | type FixedAmount struct { 11 | Amount string `json:"amount"` 12 | Currency string `json:"currency"` 13 | } 14 | 15 | // Coupon represents a discount redeemable with a code. 16 | type Coupon struct { 17 | Code string `json:"code"` 18 | 19 | StartDate *time.Time `json:"start_date,omitempty"` 20 | EndDate *time.Time `json:"end_date,omitempty"` 21 | 22 | Percentage uint64 `json:"percentage,omitempty"` 23 | FixedAmount []*FixedAmount `json:"fixed,omitempty"` 24 | 25 | ProductTypes []string `json:"product_types,omitempty"` 26 | Products []string `json:"products,omitempty"` 27 | Claims map[string]interface{} `json:"claims,omitempty"` 28 | } 29 | 30 | // Valid returns whether a coupon is valid or not. 31 | func (c *Coupon) Valid() bool { 32 | if c.StartDate != nil && time.Now().Before(*c.StartDate) { 33 | return false 34 | } 35 | if c.EndDate != nil && time.Now().After(*c.EndDate) { 36 | return false 37 | } 38 | return true 39 | } 40 | 41 | // ValidForProduct returns whether a coupon applies to a specific product. 42 | func (c *Coupon) ValidForProduct(productSku string) bool { 43 | if c == nil { 44 | return false 45 | } 46 | 47 | if c.Products == nil || len(c.Products) == 0 { 48 | return true 49 | } 50 | 51 | for _, s := range c.Products { 52 | if s == productSku { 53 | return true 54 | } 55 | } 56 | 57 | return false 58 | } 59 | 60 | // ValidForType returns whether a coupon applies to a specific product type. 61 | func (c *Coupon) ValidForType(productType string) bool { 62 | if c == nil { 63 | return false 64 | } 65 | 66 | if c.ProductTypes == nil || len(c.ProductTypes) == 0 { 67 | return true 68 | } 69 | 70 | for _, t := range c.ProductTypes { 71 | if t == productType { 72 | return true 73 | } 74 | } 75 | 76 | return false 77 | } 78 | 79 | // ValidForPrice returns whether a coupon applies to a specific amount. 80 | func (c *Coupon) ValidForPrice(currency string, price uint64) bool { 81 | // TODO: Support for coupons based on amount 82 | return true 83 | } 84 | 85 | // PercentageDiscount returns the percentage discount of a Coupon. 86 | func (c *Coupon) PercentageDiscount() uint64 { 87 | return c.Percentage 88 | } 89 | 90 | // FixedDiscount returns the amount of fixed discount for a Coupon. 91 | func (c *Coupon) FixedDiscount(currency string) uint64 { 92 | if c.FixedAmount != nil { 93 | for _, discount := range c.FixedAmount { 94 | if discount.Currency == currency { 95 | amount, _ := strconv.ParseFloat(discount.Amount, 64) 96 | return rint(amount * 100) 97 | } 98 | } 99 | } 100 | 101 | return 0 102 | } 103 | 104 | // Nopes - no `round` method in go 105 | // See https://gist.github.com/siddontang/1806573b9a8574989ccb 106 | func rint(x float64) uint64 { 107 | v, frac := math.Modf(x) 108 | if x > 0.0 { 109 | if frac > 0.5 || (frac == 0.5 && uint64(v)%2 != 0) { 110 | v += 1.0 111 | } 112 | } else { 113 | if frac < -0.5 || (frac == -0.5 && uint64(v)%2 != 0) { 114 | v -= 1.0 115 | } 116 | } 117 | 118 | return uint64(v) 119 | } 120 | -------------------------------------------------------------------------------- /models/download.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/netlify/gocommerce/assetstores" 7 | ) 8 | 9 | // Download represents a purchased asset download. 10 | type Download struct { 11 | ID string `json:"id"` 12 | 13 | OrderID string `json:"order_id"` 14 | LineItemID int64 `json:"line_item_id"` 15 | 16 | Title string `json:"title"` 17 | Sku string `json:"sku"` 18 | Format string `json:"format"` 19 | URL string `json:"url"` 20 | 21 | DownloadCount uint64 `json:"downloads"` 22 | 23 | CreatedAt time.Time `json:"created_at"` 24 | UpdatedAt time.Time `json:"updated_at"` 25 | DeletedAt *time.Time `json:"-" sql:"index"` 26 | } 27 | 28 | // TableName returns the database table name for the Download model. 29 | func (Download) TableName() string { 30 | return tableName("downloads") 31 | } 32 | 33 | // SignURL signs a download URL using the provided asset store. 34 | func (d *Download) SignURL(store assetstores.Store) error { 35 | signedURL, err := store.SignURL(d.URL) 36 | if err != nil { 37 | return err 38 | } 39 | d.URL = signedURL 40 | 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /models/errors.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | // IsNotFoundError returns whether an error represents a "not found" error. 4 | func IsNotFoundError(err error) bool { 5 | switch err.(type) { 6 | case ModelNotFoundError: 7 | return true 8 | } 9 | return false 10 | } 11 | 12 | // ModelNotFoundError represents when an instance is not found. 13 | type ModelNotFoundError struct { 14 | modelName string 15 | } 16 | 17 | func (e ModelNotFoundError) Error() string { 18 | return e.modelName + " not found" 19 | } 20 | -------------------------------------------------------------------------------- /models/event.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | // Event represents a change to an order. 11 | type Event struct { 12 | ID uint64 `json:"id"` 13 | 14 | IP string `json:"ip"` 15 | 16 | User *User `json:"user,omitempty"` 17 | UserID string `json:"user_id,omitempty"` 18 | 19 | Order *Order `json:"order,omitempty"` 20 | OrderID string `json:"order_id,omitempty"` 21 | 22 | Type string `json:"type"` 23 | Changes string `json:"data"` 24 | 25 | CreatedAt time.Time `json:"created_at"` 26 | } 27 | 28 | // TableName returns the database table name for the Event model. 29 | func (Event) TableName() string { 30 | return tableName("events") 31 | } 32 | 33 | // EventType is the type of change that occurred. 34 | type EventType string 35 | 36 | const ( 37 | // EventCreated is the EventType when an order is created. 38 | EventCreated EventType = "created" 39 | // EventUpdated is the EventType when an order is updated. 40 | EventUpdated EventType = "updated" 41 | // EventDeleted is the EventType when an order is deleted. 42 | EventDeleted EventType = "deleted" 43 | ) 44 | 45 | // LogEvent logs a new event 46 | func LogEvent(db *gorm.DB, ip, userID, orderID string, eventType EventType, changes []string) { 47 | event := &Event{ 48 | IP: ip, 49 | UserID: userID, 50 | OrderID: orderID, 51 | Type: string(eventType), 52 | } 53 | if changes != nil { 54 | event.Changes = strings.Join(changes, ",") 55 | } 56 | db.Create(event) 57 | } 58 | -------------------------------------------------------------------------------- /models/helpers.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/jinzhu/gorm" 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | // cm should be pointer to a slice, e.g. &[]User{} 12 | func cascadeDelete(tx *gorm.DB, query string, id interface{}, name string, cm interface{}) error { 13 | if result := tx.Where(query, id).Find(cm); result.Error != nil { 14 | return errors.Wrap(result.Error, fmt.Sprintf("Error deleting %s records", name)) 15 | } 16 | 17 | // get direct reference to slice for loop 18 | t := reflect.Indirect(reflect.ValueOf(cm)) 19 | for i := 0; i < t.Len(); i++ { 20 | // get pointer to model 21 | o := t.Index(i).Addr().Interface() 22 | if result := tx.Delete(o); result.Error != nil { 23 | return errors.Wrapf(result.Error, "Error deleting %s", name) 24 | } 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /models/hook.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | "sync" 10 | "time" 11 | 12 | jwt "github.com/golang-jwt/jwt/v4" 13 | "github.com/jinzhu/gorm" 14 | "github.com/pborman/uuid" 15 | "github.com/pkg/errors" 16 | "github.com/sirupsen/logrus" 17 | ) 18 | 19 | const maxConcurrentHooks = 5 20 | const maxRetries = 5 21 | const retryPeriod = 30 * time.Second 22 | const signatureExpiration = 5 * time.Minute 23 | 24 | // Hook represents a webhook. 25 | type Hook struct { 26 | ID uint64 27 | 28 | UserID string 29 | 30 | Type string 31 | 32 | Done bool 33 | Failed bool 34 | 35 | URL string 36 | Payload string `sql:"type:text"` 37 | Secret string 38 | 39 | ResponseStatus string 40 | ResponseHeaders string `sql:"type:text"` 41 | ResponseBody string `sql:"type:text"` 42 | ErrorMessage *string `sql:"type:text"` 43 | 44 | Tries int 45 | 46 | CreatedAt time.Time 47 | RunAfter *time.Time 48 | LockedAt *time.Time 49 | LockedBy *string 50 | CompletedAt *time.Time 51 | } 52 | 53 | // TableName returns the database table name for the Hook model. 54 | func (Hook) TableName() string { 55 | return tableName("hooks") 56 | } 57 | 58 | // NewHook creates a Hook model. 59 | func NewHook(hookType, siteURL, hookURL, userID, secret string, payload interface{}) (*Hook, error) { 60 | fullHookURL, err := url.Parse(hookURL) 61 | if err != nil { 62 | return nil, errors.Wrapf(err, "Failed to parse Webhook URL") 63 | } 64 | 65 | if !fullHookURL.IsAbs() { 66 | fullSiteURL, err := url.Parse(siteURL) 67 | if err != nil { 68 | return nil, errors.Wrapf(err, "Failed to parse Site URL") 69 | } 70 | fullHookURL.Scheme = fullSiteURL.Scheme 71 | fullHookURL.Host = fullSiteURL.Host 72 | fullHookURL.User = fullSiteURL.User 73 | } 74 | 75 | json, _ := json.Marshal(payload) 76 | return &Hook{ 77 | Type: hookType, 78 | UserID: userID, 79 | URL: fullHookURL.String(), 80 | Secret: secret, 81 | Payload: string(json), 82 | }, nil 83 | } 84 | 85 | // Trigger creates and executes the HTTP request for a Hook. 86 | func (h *Hook) Trigger(client *http.Client, log *logrus.Entry) (*http.Response, error) { 87 | log.Infof("Triggering hook %v: %v", h.ID, h.URL) 88 | h.Tries++ 89 | body := bytes.NewBufferString(h.Payload) 90 | req, err := http.NewRequest("POST", h.URL, body) 91 | req.Header.Set("Content-Type", "application/json") 92 | if err != nil { 93 | return nil, err 94 | } 95 | if h.Secret != "" { 96 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ 97 | "sub": h.UserID, 98 | "exp": time.Now().Add(signatureExpiration).Unix(), 99 | }) 100 | tokenString, err := token.SignedString([]byte(h.Secret)) 101 | if err != nil { 102 | return nil, err 103 | } 104 | req.Header.Set("X-Commerce-Signature", tokenString) 105 | } 106 | return client.Do(req) 107 | } 108 | 109 | func (h *Hook) handleError(db *gorm.DB, log *logrus.Entry, resp *http.Response, err error) { 110 | if err != nil { 111 | errString := err.Error() 112 | h.ErrorMessage = &errString 113 | } else { 114 | h.ErrorMessage = nil 115 | } 116 | 117 | if resp != nil && resp.Body != nil { 118 | body, _ := ioutil.ReadAll(resp.Body) 119 | h.ResponseBody = string(body) 120 | h.ResponseStatus = resp.Status 121 | headers, _ := json.Marshal(resp.Header) 122 | h.ResponseHeaders = string(headers) 123 | } 124 | 125 | now := time.Now() 126 | if h.Tries >= maxRetries { 127 | log.Errorf("Hook %v failed more than %v times. %v. Giving up.", h.ID, maxRetries, err) 128 | h.Failed = true 129 | h.Done = true 130 | h.CompletedAt = &now 131 | } else { 132 | runAfter := now.Add(time.Duration(h.Tries) * retryPeriod) 133 | h.RunAfter = &runAfter 134 | log.Errorf("Hook %v failed %v - retrying at %v", h.ID, err, runAfter) 135 | } 136 | db.Save(h) 137 | } 138 | 139 | func (h *Hook) handleSuccess(db *gorm.DB, log *logrus.Entry, resp *http.Response) { 140 | log.Infof("Hook %v triggered. %v", h.ID, resp.Status) 141 | now := time.Now() 142 | h.Done = true 143 | h.ErrorMessage = nil 144 | h.ResponseStatus = resp.Status 145 | headers, _ := json.Marshal(resp.Header) 146 | h.ResponseHeaders = string(headers) 147 | body, _ := ioutil.ReadAll(resp.Body) 148 | h.ResponseBody = string(body) 149 | h.CompletedAt = &now 150 | db.Save(h) 151 | } 152 | 153 | // RunHooks creates a goroutine that triggers stored webhooks every 5 seconds. 154 | func RunHooks(db *gorm.DB, log *logrus.Entry) { 155 | go func() { 156 | id := uuid.NewRandom().String() 157 | sem := make(chan bool, maxConcurrentHooks) 158 | table := Hook{}.TableName() 159 | client := &http.Client{} 160 | for { 161 | hooks := []*Hook{} 162 | tx := db.Begin() 163 | now := time.Now() 164 | 165 | tx.Table(table). 166 | Where("done = ? AND (locked_at IS NULL OR locked_at < ?) AND (run_after IS NULL OR run_after < ?)", false, now.Add(-5*time.Minute), now). 167 | Updates(map[string]interface{}{"locked_at": now, "locked_by": id}) 168 | 169 | tx.Where("locked_by = ?", id).Find(&hooks) 170 | if rsp := tx.Commit(); rsp.Error != nil { 171 | log.WithError(rsp.Error).Error("Error querying for hooks") 172 | } 173 | 174 | var wg sync.WaitGroup 175 | for _, hook := range hooks { 176 | sem <- true 177 | wg.Add(1) 178 | go func(hook *Hook) { 179 | defer wg.Done() 180 | resp, err := hook.Trigger(client, log) 181 | hook.LockedAt = nil 182 | hook.LockedBy = nil 183 | tx := db.Begin() 184 | if err != nil || !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 185 | hook.handleError(tx, log, resp, err) 186 | } else { 187 | hook.handleSuccess(tx, log, resp) 188 | } 189 | tx.Commit() 190 | <-sem 191 | }(hook) 192 | } 193 | 194 | wg.Wait() 195 | time.Sleep(5 * time.Second) 196 | } 197 | }() 198 | } 199 | -------------------------------------------------------------------------------- /models/instance.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/jinzhu/gorm" 9 | "github.com/netlify/gocommerce/conf" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | const baseConfigKey = "" 14 | 15 | type Instance struct { 16 | ID string `json:"id"` 17 | // Netlify UUID 18 | UUID string `json:"uuid,omitempty"` 19 | 20 | RawBaseConfig string `json:"-" sql:"type:text"` 21 | BaseConfig *conf.Configuration `json:"config"` 22 | 23 | CreatedAt time.Time `json:"created_at"` 24 | UpdatedAt time.Time `json:"updated_at"` 25 | DeletedAt *time.Time `json:"deleted_at"` 26 | } 27 | 28 | // TableName returns the table name used for the Instance model 29 | func (i *Instance) TableName() string { 30 | return tableName("instances") 31 | } 32 | 33 | // AfterFind database callback. 34 | func (i *Instance) AfterFind() error { 35 | if i.RawBaseConfig != "" { 36 | err := json.Unmarshal([]byte(i.RawBaseConfig), &i.BaseConfig) 37 | if err != nil { 38 | return err 39 | } 40 | } 41 | return nil 42 | } 43 | 44 | // BeforeSave database callback. 45 | func (i *Instance) BeforeSave() error { 46 | if i.BaseConfig != nil { 47 | data, err := json.Marshal(i.BaseConfig) 48 | if err != nil { 49 | return err 50 | } 51 | i.RawBaseConfig = string(data) 52 | } 53 | return nil 54 | } 55 | 56 | // Config loads the configuration and applies defaults. 57 | func (i *Instance) Config() (*conf.Configuration, error) { 58 | if i.BaseConfig == nil { 59 | return nil, errors.New("no configuration data available") 60 | } 61 | 62 | baseConf := &conf.Configuration{} 63 | *baseConf = *i.BaseConfig 64 | baseConf.ApplyDefaults() 65 | 66 | return baseConf, nil 67 | } 68 | 69 | // GetInstance finds an instance by ID 70 | func GetInstance(db *gorm.DB, instanceID string) (*Instance, error) { 71 | instance := Instance{} 72 | if rsp := db.Where("id = ?", instanceID).First(&instance); rsp.Error != nil { 73 | if rsp.RecordNotFound() { 74 | return nil, ModelNotFoundError{"instance"} 75 | } 76 | return nil, errors.Wrap(rsp.Error, "error finding instance") 77 | } 78 | return &instance, nil 79 | } 80 | 81 | func GetInstanceByUUID(db *gorm.DB, uuid string) (*Instance, error) { 82 | instance := Instance{} 83 | if rsp := db.Where("uuid = ?", uuid).First(&instance); rsp.Error != nil { 84 | if rsp.RecordNotFound() { 85 | return nil, ModelNotFoundError{"instance"} 86 | } 87 | return nil, errors.Wrap(rsp.Error, "error finding instance") 88 | } 89 | return &instance, nil 90 | } 91 | 92 | func CreateInstance(db *gorm.DB, instance *Instance) error { 93 | if result := db.Create(instance); result.Error != nil { 94 | return errors.Wrap(result.Error, "Error creating instance") 95 | } 96 | return nil 97 | } 98 | 99 | func UpdateInstance(db *gorm.DB, instance *Instance) error { 100 | if result := db.Save(instance); result.Error != nil { 101 | return errors.Wrap(result.Error, "Error updating instance record") 102 | } 103 | return nil 104 | } 105 | 106 | func DeleteInstance(db *gorm.DB, instance *Instance) error { 107 | return db.Delete(instance).Error 108 | } 109 | 110 | func (i *Instance) BeforeDelete(tx *gorm.DB) error { 111 | cascadeModels := map[string]interface{}{ 112 | "order": &[]Order{}, 113 | "user": &[]User{}, 114 | } 115 | for name, cm := range cascadeModels { 116 | if err := cascadeDelete(tx, "instance_id = ?", i.ID, name, cm); err != nil { 117 | return err 118 | } 119 | } 120 | 121 | delModels := map[string]interface{}{ 122 | "transaction": Transaction{}, 123 | "invoice number": InvoiceNumber{}, 124 | } 125 | 126 | for name, dm := range delModels { 127 | if result := tx.Delete(dm, "instance_id = ?", i.ID); result.Error != nil { 128 | return errors.Wrap(result.Error, fmt.Sprintf("Error deleting %s records", name)) 129 | } 130 | } 131 | return nil 132 | } 133 | -------------------------------------------------------------------------------- /models/invoice_number.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/jinzhu/gorm" 8 | ) 9 | 10 | type InvoiceNumber struct { 11 | InstanceID string `gorm:"primary_key"` 12 | Number int64 13 | } 14 | 15 | // TableName returns the database table name for the LineItem model. 16 | func (InvoiceNumber) TableName() string { 17 | return tableName("invoice_numbers") 18 | } 19 | 20 | // NextInvoiceNumber updates and returns the next invoice number for the instance 21 | func NextInvoiceNumber(tx *gorm.DB, instanceID string) (int64, error) { 22 | number := InvoiceNumber{} 23 | if instanceID == "" { 24 | instanceID = "global-instance" 25 | } 26 | 27 | if result := tx.Where(InvoiceNumber{InstanceID: instanceID}).Attrs(InvoiceNumber{Number: 0}).FirstOrCreate(&number); result.Error != nil { 28 | return 0, result.Error 29 | } 30 | 31 | numberTable := tx.NewScope(InvoiceNumber{}).QuotedTableName() 32 | if result := tx.Raw("select number from "+numberTable+" where instance_id = ? for update", instanceID).Scan(&number); result.Error != nil { 33 | if strings.Contains(result.Error.Error(), "syntax error") { 34 | log.Println("This DB driver doesn't support select for update, hoping for the best...") 35 | } else { 36 | return 0, result.Error 37 | } 38 | } 39 | if result := tx.Model(number).Update("number", gorm.Expr("number + 1")); result.Error != nil { 40 | return 0, result.Error 41 | } 42 | 43 | return number.Number + 1, nil 44 | } 45 | -------------------------------------------------------------------------------- /models/order.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/jinzhu/gorm" 10 | "github.com/netlify/gocommerce/calculator" 11 | "github.com/netlify/gocommerce/conf" 12 | "github.com/pborman/uuid" 13 | "github.com/pkg/errors" 14 | "github.com/sirupsen/logrus" 15 | ) 16 | 17 | // PendingState is the pending state of an Order 18 | const PendingState = "pending" 19 | 20 | // PaidState is the paid state of an Order 21 | const PaidState = "paid" 22 | 23 | // ShippingState is the shipping state of an order 24 | const ShippingState = "shipping" 25 | 26 | // ShippedState is the shipped state of an Order 27 | const ShippedState = "shipped" 28 | 29 | // FailedState is the failed state of an Order 30 | const FailedState = "failed" 31 | 32 | // PaymentState are the possible values for the PaymentState field 33 | var PaymentStates = []string{ 34 | PendingState, 35 | PaidState, 36 | FailedState, 37 | } 38 | 39 | // FulfillmentStates are the possible values for the FulfillmentState field 40 | var FulfillmentStates = []string{ 41 | PendingState, 42 | ShippingState, 43 | ShippedState, 44 | } 45 | 46 | // NumberType | StringType | BoolType are the different types supported in custom data for orders 47 | const ( 48 | NumberType = iota 49 | StringType 50 | BoolType 51 | ) 52 | 53 | // Order model 54 | type Order struct { 55 | InstanceID string `json:"-" sql:"index"` 56 | ID string `json:"id"` 57 | InvoiceNumber int64 `json:"invoice_number,omitempty"` 58 | 59 | IP string `json:"ip"` 60 | 61 | User *User `json:"user,omitempty"` 62 | UserID string `json:"user_id,omitempty"` 63 | SessionID string `json:"-"` 64 | 65 | Email string `json:"email"` 66 | 67 | LineItems []*LineItem `json:"line_items"` 68 | 69 | Downloads []Download `json:"downloads"` 70 | 71 | Currency string `json:"currency"` 72 | Taxes uint64 `json:"taxes"` 73 | Shipping uint64 `json:"shipping"` 74 | SubTotal uint64 `json:"subtotal"` 75 | Discount uint64 `json:"discount"` 76 | NetTotal uint64 `json:"net_total"` 77 | 78 | Total uint64 `json:"total"` 79 | 80 | PaymentState string `json:"payment_state"` 81 | FulfillmentState string `json:"fulfillment_state"` 82 | State string `json:"state"` 83 | 84 | PaymentProcessor string `json:"payment_processor"` 85 | 86 | Transactions []*Transaction `json:"transactions"` 87 | Notes []*OrderNote `json:"notes"` 88 | 89 | ShippingAddress Address `json:"shipping_address" gorm:"ForeignKey:ShippingAddressID"` 90 | ShippingAddressID string `json:"shipping_address_id"` 91 | 92 | BillingAddress Address `json:"billing_address" gorm:"ForeignKey:BillingAddressID"` 93 | BillingAddressID string `json:"billing_address_id"` 94 | 95 | VATNumber string `json:"vatnumber"` 96 | 97 | MetaData map[string]interface{} `sql:"-" json:"meta"` 98 | RawMetaData string `json:"-" sql:"type:text"` 99 | 100 | CouponCode string `json:"coupon_code,omitempty"` 101 | 102 | Coupon *Coupon `json:"coupon,omitempty" sql:"-"` 103 | RawCoupon string `json:"-" sql:"type:text"` 104 | 105 | CreatedAt time.Time `json:"created_at" sql:"index"` 106 | UpdatedAt time.Time `json:"updated_at"` 107 | DeletedAt *time.Time `json:"-" sql:"index"` 108 | 109 | ModificationLock sync.Mutex `json:"-" sql:"-"` 110 | } 111 | 112 | // TableName returns the database table name for the Order model. 113 | func (Order) TableName() string { 114 | return tableName("orders") 115 | } 116 | 117 | // AfterFind database callback. 118 | func (o *Order) AfterFind() error { 119 | if o.RawMetaData != "" { 120 | err := json.Unmarshal([]byte(o.RawMetaData), &o.MetaData) 121 | if err != nil { 122 | return err 123 | } 124 | } 125 | if o.RawCoupon != "" { 126 | o.Coupon = &Coupon{} 127 | err := json.Unmarshal([]byte(o.RawCoupon), &o.Coupon) 128 | if err != nil { 129 | return err 130 | } 131 | } 132 | 133 | return nil 134 | } 135 | 136 | // BeforeSave database callback. 137 | func (o *Order) BeforeSave() error { 138 | if o.MetaData != nil { 139 | data, err := json.Marshal(o.MetaData) 140 | if err != nil { 141 | return err 142 | } 143 | o.RawMetaData = string(data) 144 | } 145 | if o.Coupon != nil { 146 | data, err := json.Marshal(o.Coupon) 147 | if err != nil { 148 | return err 149 | } 150 | o.RawCoupon = string(data) 151 | } 152 | 153 | return nil 154 | } 155 | 156 | // NewOrder creates a new pending Order. 157 | func NewOrder(instanceID, sessionID, email, currency string) *Order { 158 | order := &Order{ 159 | InstanceID: instanceID, 160 | ID: uuid.NewRandom().String(), 161 | SessionID: sessionID, 162 | Email: email, 163 | Currency: currency, 164 | } 165 | order.PaymentState = PendingState 166 | order.FulfillmentState = PendingState 167 | order.State = PendingState 168 | return order 169 | } 170 | 171 | // CalculateTotal calculates the total price of an Order. 172 | func (o *Order) CalculateTotal(settings *calculator.Settings, claims map[string]interface{}, log logrus.FieldLogger) { 173 | items := make([]calculator.Item, len(o.LineItems)) 174 | for i, item := range o.LineItems { 175 | items[i] = item 176 | } 177 | 178 | params := calculator.PriceParameters{o.ShippingAddress.Country, o.Currency, o.Coupon, items} 179 | price := calculator.CalculatePrice(settings, claims, params, log) 180 | 181 | o.SubTotal = price.Subtotal 182 | o.Taxes = price.Taxes 183 | o.Discount = price.Discount 184 | o.NetTotal = price.NetTotal 185 | 186 | // apply price details to line items 187 | for i, item := range price.Items { 188 | o.LineItems[i].CalculationDetail = &CalculationDetail{ 189 | Discount: item.Discount, 190 | Subtotal: item.Subtotal, 191 | NetTotal: item.NetTotal, 192 | Taxes: item.Taxes, 193 | Total: item.Total, 194 | } 195 | 196 | for _, discount := range item.DiscountItems { 197 | discount := DiscountItem{ 198 | DiscountItem: discount, 199 | } 200 | o.LineItems[i].CalculationDetail.DiscountItems = append(o.LineItems[i].CalculationDetail.DiscountItems, discount) 201 | } 202 | } 203 | 204 | if price.Total > 0 { 205 | o.Total = uint64(price.Total) 206 | } 207 | } 208 | 209 | // UpdateDownloads will refetch downloads for all line items in the order and 210 | // update the downloads in the order 211 | func (o *Order) UpdateDownloads(config *conf.Configuration, log logrus.FieldLogger) error { 212 | updateMap := downloadRefreshItemSet{} 213 | for _, item := range o.LineItems { 214 | updateMap.Add(item, o) 215 | } 216 | updates, err := updateMap.Update(nil, config, log) 217 | log.Debugf("Updated downloads of %d orders", len(updates)) 218 | return err 219 | } 220 | 221 | func (o *Order) BeforeDelete(tx *gorm.DB) error { 222 | cascadeModels := map[string]interface{}{ 223 | "line item": &[]LineItem{}, 224 | } 225 | for name, cm := range cascadeModels { 226 | if err := cascadeDelete(tx, "order_id = ?", o.ID, name, cm); err != nil { 227 | return err 228 | } 229 | } 230 | 231 | delModels := map[string]interface{}{ 232 | "event": Event{}, 233 | "transaction": Transaction{}, 234 | "download": Download{}, 235 | } 236 | for name, dm := range delModels { 237 | if result := tx.Delete(dm, "order_id = ?", o.ID); result.Error != nil { 238 | return errors.Wrap(result.Error, fmt.Sprintf("Error deleting %s records", name)) 239 | } 240 | } 241 | return nil 242 | } 243 | 244 | type downloadRefreshItemSetEntry struct { 245 | item *LineItem 246 | orders []*Order 247 | } 248 | type downloadRefreshInstanceItems map[string]*downloadRefreshItemSetEntry 249 | type downloadRefreshItemSet map[string]downloadRefreshInstanceItems 250 | 251 | // Add will take a line item and an order to persist in 252 | // the list of orders to update 253 | func (m downloadRefreshItemSet) Add(item *LineItem, order *Order) { 254 | instance, ok := m[order.InstanceID] 255 | if !ok { 256 | instance = make(map[string]*downloadRefreshItemSetEntry) 257 | m[order.InstanceID] = instance 258 | } 259 | 260 | mapping, ok := instance[item.Sku] 261 | if !ok { 262 | mapping = &downloadRefreshItemSetEntry{ 263 | item: item, 264 | orders: []*Order{}, 265 | } 266 | instance[item.Sku] = mapping 267 | } 268 | 269 | mapping.orders = append(mapping.orders, order) 270 | } 271 | 272 | // UpdateDownloads fetches downloads for all line items and updates orders with new downloads 273 | func (m downloadRefreshItemSet) Update(db *gorm.DB, config *conf.Configuration, log logrus.FieldLogger) (updates []*Order, err error) { 274 | // @todo: run in parallel with goroutines, lock orders with mutexes 275 | for instanceID, items := range m { 276 | if config == nil { 277 | if db == nil { 278 | err = errors.New("Instance config or database connection missing") 279 | return 280 | } 281 | instance := Instance{} 282 | if queryErr := db.First(&instance, Instance{ID: instanceID}).Error; queryErr != nil { 283 | err = errors.Wrap(queryErr, "Failed fetching instance for order") 284 | return 285 | } 286 | config = instance.BaseConfig 287 | } 288 | 289 | for _, entry := range items { 290 | if entry.item.Sku == "" { 291 | log.Warningf( 292 | "Tried updating a line item without SKU at %s. Skipped to avoid memory update in FetchMeta", 293 | entry.item.Path, 294 | ) 295 | continue 296 | } 297 | log.Debugf("Updating downloads for item with sku '%s'", entry.item.Sku) 298 | meta, fetchErr := entry.item.FetchMeta(config.SiteURL) 299 | if fetchErr != nil { 300 | // item might not be offered anymore, preserve downloads 301 | log.WithError(fetchErr). 302 | WithFields(map[string]interface{}{ 303 | "path": entry.item.Path, 304 | "sku": entry.item.Sku, 305 | }). 306 | Warning("Fetching product metadata failed. Skipping item.") 307 | continue 308 | } 309 | for _, order := range entry.orders { 310 | downloads := entry.item.MissingDownloads(order, meta) 311 | if len(downloads) == 0 { 312 | continue 313 | } 314 | // @todo: Lock order mutex if run in goroutines 315 | order.Downloads = append(order.Downloads, downloads...) 316 | 317 | updates = append(updates, order) 318 | } 319 | } 320 | } 321 | 322 | return 323 | } 324 | -------------------------------------------------------------------------------- /models/order_notes.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "time" 4 | 5 | // OrderNote model which represent notes on a model. 6 | type OrderNote struct { 7 | ID int64 `json:"-"` 8 | 9 | UserID string `json:"user_id"` 10 | 11 | Text string `json:"text" sql:"type:text"` 12 | 13 | CreatedAt time.Time `json:"created_at"` 14 | UpdatedAt time.Time `json:"updated_at"` 15 | DeletedAt *time.Time `json:"-"` 16 | } 17 | 18 | // TableName returns the database table name for the OrderNote model. 19 | func (OrderNote) TableName() string { 20 | return tableName("orders_notes") 21 | } 22 | -------------------------------------------------------------------------------- /models/transaction.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/jinzhu/gorm" 7 | "github.com/pborman/uuid" 8 | ) 9 | 10 | // ChargeTransactionType is the charge transaction type. 11 | const ChargeTransactionType = "charge" 12 | 13 | // RefundTransactionType is the refund transaction type. 14 | const RefundTransactionType = "refund" 15 | 16 | // Transaction is an transaction with a payment provider 17 | type Transaction struct { 18 | InstanceID string `json:"-"` 19 | ID string `json:"id"` 20 | Order *Order `json:"-"` 21 | OrderID string `json:"order_id"` 22 | InvoiceNumber int64 `json:"invoice_number"` 23 | 24 | ProcessorID string `json:"processor_id"` 25 | 26 | User *User `json:"-"` 27 | UserID string `json:"user_id,omitempty"` 28 | 29 | Amount uint64 `json:"amount"` 30 | Currency string `json:"currency"` 31 | 32 | FailureCode string `json:"failure_code,omitempty"` 33 | FailureDescription string `json:"failure_description,omitempty" sql:"type:text"` 34 | 35 | Status string `json:"status"` 36 | Type string `json:"type"` 37 | 38 | CreatedAt time.Time `json:"created_at"` 39 | DeletedAt *time.Time `json:"-"` 40 | 41 | ProviderMetadata map[string]interface{} `json:"provider_metadata,omitempty" sql:"-"` 42 | } 43 | 44 | // TableName returns the database table name for the Transaction model. 45 | func (Transaction) TableName() string { 46 | return tableName("transactions") 47 | } 48 | 49 | // NewTransaction returns a new transaction for an order 50 | func NewTransaction(order *Order) *Transaction { 51 | return &Transaction{ 52 | InstanceID: order.InstanceID, 53 | ID: uuid.NewRandom().String(), 54 | Order: order, 55 | OrderID: order.ID, 56 | User: order.User, 57 | UserID: order.UserID, 58 | Currency: order.Currency, 59 | Amount: order.Total, 60 | Type: ChargeTransactionType, 61 | } 62 | } 63 | 64 | func GetTransaction(db *gorm.DB, id string) (*Transaction, error) { 65 | trans := &Transaction{ID: id} 66 | if rsp := db.First(trans); rsp.Error != nil { 67 | if rsp.RecordNotFound() { 68 | return nil, nil 69 | } 70 | return nil, rsp.Error 71 | } 72 | return trans, nil 73 | } 74 | -------------------------------------------------------------------------------- /models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/go-sql-driver/mysql" 9 | "github.com/jinzhu/gorm" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // User model 14 | type User struct { 15 | InstanceID string `json:"-"` 16 | ID string `json:"id"` 17 | Email string `json:"email"` 18 | Name string `json:"name"` 19 | 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | DeletedAt *time.Time `json:"-"` 23 | 24 | OrderCount int64 `json:"order_count" gorm:"-"` 25 | LastOrderAt *HackyNullTime `json:"last_order_at" gorm:"-"` 26 | } 27 | 28 | // @todo: replace with mysql.NullTime once the tests no longer use SQLite 29 | type HackyNullTime struct { 30 | Time time.Time 31 | Valid bool 32 | } 33 | 34 | func (t *HackyNullTime) Scan(value interface{}) (err error) { 35 | if value == nil { 36 | t.Valid = false 37 | return nil 38 | } 39 | 40 | // try parsing with mysql time format 41 | var parsingTime mysql.NullTime 42 | if err := parsingTime.Scan(value); err == nil { 43 | t.Time = parsingTime.Time 44 | t.Valid = parsingTime.Valid 45 | return nil 46 | } 47 | 48 | // fallback to sqlite time format 49 | timeFormat := "2006-01-02 15:04:05.999999-07:00" 50 | 51 | switch v := value.(type) { 52 | case []byte: 53 | t.Time, err = time.Parse(timeFormat, string(v)) 54 | t.Valid = (err == nil) 55 | case string: 56 | t.Time, err = time.Parse(timeFormat, v) 57 | t.Valid = (err == nil) 58 | } 59 | 60 | return nil 61 | } 62 | 63 | func (t *HackyNullTime) MarshalJSON() ([]byte, error) { 64 | if !t.Valid { 65 | return json.Marshal(nil) 66 | } 67 | 68 | return json.Marshal(t.Time) 69 | } 70 | 71 | func (t *HackyNullTime) UnmarshalJSON(data []byte) error { 72 | time := time.Time{} 73 | err := time.UnmarshalJSON(data) 74 | if err == nil && !time.IsZero() { 75 | t.Valid = true 76 | t.Time = time 77 | return nil 78 | } 79 | t.Valid = false 80 | return nil 81 | } 82 | 83 | // TableName returns the database table name for the User model. 84 | func (User) TableName() string { 85 | return tableName("users") 86 | } 87 | 88 | func GetUser(db *gorm.DB, userID string) (*User, error) { 89 | user := &User{ID: userID} 90 | if result := db.Find(user); result.Error != nil { 91 | if result.RecordNotFound() { 92 | return nil, nil 93 | } 94 | return nil, result.Error 95 | } 96 | return user, nil 97 | } 98 | 99 | func (u *User) BeforeDelete(tx *gorm.DB) error { 100 | cascadeModels := map[string]interface{}{ 101 | "order": &[]Order{}, 102 | } 103 | for name, cm := range cascadeModels { 104 | if err := cascadeDelete(tx, "user_id = ?", u.ID, name, cm); err != nil { 105 | return err 106 | } 107 | } 108 | 109 | delModels := map[string]interface{}{ 110 | "address": Address{}, 111 | "hook": Hook{}, 112 | "transaction": Transaction{}, 113 | "order note": OrderNote{}, 114 | } 115 | for name, dm := range delModels { 116 | if result := tx.Delete(dm, "user_id = ?", u.ID); result.Error != nil { 117 | return errors.Wrap(result.Error, fmt.Sprintf("Error deleting %s records", name)) 118 | } 119 | } 120 | return nil 121 | } 122 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "www" 3 | command = "exit 0" 4 | 5 | [[redirects]] 6 | from = "/*" 7 | to = "https://github.com/netlify/gocommerce" 8 | status = 302 9 | force = true 10 | -------------------------------------------------------------------------------- /payments/payments.go: -------------------------------------------------------------------------------- 1 | package payments 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/netlify/gocommerce/models" 8 | "github.com/sirupsen/logrus" 9 | ) 10 | 11 | const ( 12 | // StripeProvider is the string identifier for the Stripe payment provider. 13 | StripeProvider = "stripe" 14 | // PayPalProvider is the string identifier for the PayPal payment provider. 15 | PayPalProvider = "paypal" 16 | ) 17 | 18 | // Provider represents a payment provider that can optionally charge, refund, 19 | // preauthorize payments. 20 | type Provider interface { 21 | Name() string 22 | NewCharger(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Charger, error) 23 | NewRefunder(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Refunder, error) 24 | NewPreauthorizer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Preauthorizer, error) 25 | NewConfirmer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (Confirmer, error) 26 | } 27 | 28 | // Charger wraps the Charge method which creates new payments with the provider. 29 | type Charger func(amount uint64, currency string, order *models.Order, invoiceNumber int64) (string, error) 30 | 31 | // Refunder wraps the Refund method which refunds payments with the provider. 32 | type Refunder func(transactionID string, amount uint64, currency string) (string, error) 33 | 34 | // Preauthorizer wraps the Preauthorize method which pre-authorizes a payment 35 | // with the provider. 36 | type Preauthorizer func(amount uint64, currency string, description string) (*PreauthorizationResult, error) 37 | 38 | // PreauthorizationResult contains the data returned from a Preauthorization. 39 | type PreauthorizationResult struct { 40 | ID string `json:"id"` 41 | } 42 | 43 | // Confirmer wraps a confirm method used for checking two-step payments in a synchronous flow 44 | type Confirmer func(paymentID string) error 45 | 46 | // PaymentPendingError is returned when the payment provider requests additional action 47 | // e.g. 2-step authorization through 3D secure 48 | type PaymentPendingError struct { 49 | metadata map[string]interface{} 50 | } 51 | 52 | // NewPaymentPendingError creates an error for a pending action on a payment 53 | func NewPaymentPendingError(metadata map[string]interface{}) error { 54 | return &PaymentPendingError{metadata} 55 | } 56 | 57 | func (p *PaymentPendingError) Error() string { 58 | return "The payment provider requested additional actions on the transaction." 59 | } 60 | 61 | // Metadata returns fields that should be passed to the client 62 | // for use in additional actions 63 | func (p *PaymentPendingError) Metadata() map[string]interface{} { 64 | return p.metadata 65 | } 66 | 67 | // PaymentConfirmFailError is returned when the confirmation request got a negative response 68 | type PaymentConfirmFailError struct { 69 | message string 70 | } 71 | 72 | // NewPaymentConfirmFailError creates an error to use when a payment confirmation fails 73 | func NewPaymentConfirmFailError(msg string) error { 74 | return &PaymentConfirmFailError{message: msg} 75 | } 76 | 77 | func (p *PaymentConfirmFailError) Error() string { 78 | return p.message 79 | } 80 | -------------------------------------------------------------------------------- /payments/paypal/paypal.go: -------------------------------------------------------------------------------- 1 | package paypal 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/netlify/gocommerce/models" 13 | "github.com/pariz/gountries" 14 | "github.com/sirupsen/logrus" 15 | 16 | paypalsdk "github.com/netlify/PayPal-Go-SDK" 17 | "github.com/netlify/gocommerce/conf" 18 | gcontext "github.com/netlify/gocommerce/context" 19 | "github.com/netlify/gocommerce/payments" 20 | "github.com/pborman/uuid" 21 | "github.com/pkg/errors" 22 | ) 23 | 24 | type paypalPaymentProvider struct { 25 | client *paypalsdk.Client 26 | profile *paypalsdk.WebProfile 27 | profileMutex sync.Mutex 28 | } 29 | 30 | type paypalBodyParams struct { 31 | PaypalID string `json:"paypal_payment_id"` 32 | PaypalUserID string `json:"paypal_user_id"` 33 | } 34 | 35 | // Config contains PayPal-specific configuration for payment providers. 36 | type Config struct { 37 | ClientID string `mapstructure:"client_id" json:"client_id"` 38 | Secret string `mapstructure:"secret" json:"secret"` 39 | Env string `mapstructure:"env" json:"env"` 40 | } 41 | 42 | // NewPaymentProvider creates a new PayPal payment provider using the provided configuration. 43 | func NewPaymentProvider(config Config) (payments.Provider, error) { 44 | var paypal *paypalsdk.Client 45 | if config.ClientID == "" || config.Secret == "" { 46 | return nil, errors.New("missing PayPal client_id and/or secret") 47 | } 48 | var ppEnv string 49 | if config.Env == "production" { 50 | ppEnv = paypalsdk.APIBaseLive 51 | } else if config.Env == "sandbox" { 52 | ppEnv = paypalsdk.APIBaseSandBox 53 | } else { 54 | // used for testing 55 | ppEnv = config.Env 56 | } 57 | 58 | paypal, err := paypalsdk.NewClient( 59 | config.ClientID, 60 | config.Secret, 61 | ppEnv, 62 | ) 63 | if err != nil { 64 | return nil, errors.Wrap(err, "Error configuring paypal") 65 | } 66 | _, err = paypal.GetAccessToken() 67 | if err != nil { 68 | return nil, errors.Wrap(err, "Error authorizing with paypal") 69 | } 70 | 71 | return &paypalPaymentProvider{ 72 | client: paypal, 73 | }, nil 74 | } 75 | 76 | func (p *paypalPaymentProvider) Name() string { 77 | return payments.PayPalProvider 78 | } 79 | 80 | func (p *paypalPaymentProvider) NewCharger(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Charger, error) { 81 | var bp paypalBodyParams 82 | bod, err := r.GetBody() 83 | if err != nil { 84 | return nil, err 85 | } 86 | err = json.NewDecoder(bod).Decode(&bp) 87 | if err != nil { 88 | return nil, err 89 | } 90 | if bp.PaypalID == "" || bp.PaypalUserID == "" { 91 | return nil, errors.New("Payments requires a paypal_payment_id and paypal_user_id pair") 92 | } 93 | 94 | return func(amount uint64, currency string, order *models.Order, invoiceNumber int64) (string, error) { 95 | return p.charge(log, bp.PaypalID, bp.PaypalUserID, amount, currency, order, invoiceNumber) 96 | }, nil 97 | } 98 | 99 | func prepareItemsFromOrder(order *models.Order) []paypalsdk.Item { 100 | items := []paypalsdk.Item{} 101 | for _, lineItem := range order.LineItems { 102 | item := paypalsdk.Item{ 103 | Quantity: int(lineItem.GetQuantity()), 104 | Name: lineItem.Title, 105 | Price: formatAmount(lineItem.PriceInLowestUnit()), 106 | Currency: order.Currency, 107 | SKU: lineItem.ProductSku(), 108 | Description: lineItem.Description, 109 | } 110 | if lineItem.FixedVAT() > 0 { 111 | item.Tax = fmt.Sprintf("%d%%", lineItem.FixedVAT()) 112 | } 113 | items = append(items, item) 114 | } 115 | return items 116 | } 117 | 118 | func prepareShippingAddress(addr models.Address) *paypalsdk.ShippingAddress { 119 | countryQuery := gountries.New() 120 | 121 | country, err := countryQuery.FindCountryByName(strings.ToLower(addr.Country)) 122 | if err != nil { 123 | return nil 124 | } 125 | 126 | return &paypalsdk.ShippingAddress{ 127 | RecipientName: addr.Name, 128 | Line1: addr.Address1, 129 | Line2: addr.Address2, 130 | City: addr.City, 131 | CountryCode: country.Codes.Alpha2, 132 | PostalCode: addr.Zip, 133 | State: addr.State, 134 | } 135 | } 136 | 137 | func (p *paypalPaymentProvider) updatePaymentWithOrder(paymentID string, order *models.Order, invoiceNumber int64) error { 138 | invoiceNumPatch := paypalsdk.PaymentPatch{ 139 | Operation: "add", 140 | Path: "/transactions/0/invoice_number", 141 | Value: fmt.Sprintf("%d", invoiceNumber), 142 | } 143 | 144 | itemList := paypalsdk.ItemList{ 145 | Items: prepareItemsFromOrder(order), 146 | } 147 | if a := prepareShippingAddress(order.ShippingAddress); a != nil { 148 | itemList.ShippingAddress = a 149 | } 150 | itemListPatch := paypalsdk.PaymentPatch{ 151 | Operation: "add", 152 | Path: "/transactions/0/item_list", 153 | Value: &itemList, 154 | } 155 | 156 | _, err := p.client.PatchPayment(paymentID, []paypalsdk.PaymentPatch{invoiceNumPatch, itemListPatch}) 157 | return err 158 | } 159 | 160 | func (p *paypalPaymentProvider) charge(log logrus.FieldLogger, paymentID string, userID string, amount uint64, currency string, order *models.Order, invoiceNumber int64) (string, error) { 161 | payment, err := p.client.GetPayment(paymentID) 162 | if err != nil { 163 | return "", err 164 | } 165 | if len(payment.Transactions) != 1 { 166 | return "", fmt.Errorf("The paypal payment must have exactly 1 transaction, had %v", len(payment.Transactions)) 167 | } 168 | 169 | if payment.Transactions[0].Amount == nil { 170 | return "", fmt.Errorf("No amount in this transaction %v", payment.Transactions[0]) 171 | } 172 | 173 | transactionValue := fmt.Sprintf("%.2f", float64(amount)/100) 174 | 175 | if transactionValue != payment.Transactions[0].Amount.Total || payment.Transactions[0].Amount.Currency != currency { 176 | return "", fmt.Errorf("The Amount in the transaction doesn't match the amount for the order: %v", payment.Transactions[0].Amount) 177 | } 178 | 179 | if err := p.updatePaymentWithOrder(paymentID, order, invoiceNumber); err != nil { 180 | log := log.WithError(err) 181 | switch e := err.(type) { 182 | case *paypalsdk.ErrorResponse: 183 | log = log.WithField("err_detail", e.Details) 184 | } 185 | log.Warn("Failed to update transaction with details") 186 | } 187 | 188 | executeResult, err := p.client.ExecuteApprovedPayment(paymentID, userID) 189 | if err != nil { 190 | return "", err 191 | } 192 | 193 | return executeResult.ID, nil 194 | } 195 | 196 | func (p *paypalPaymentProvider) NewRefunder(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Refunder, error) { 197 | return p.refund, nil 198 | } 199 | 200 | func (p *paypalPaymentProvider) refund(transactionID string, amount uint64, currency string) (string, error) { 201 | amt := &paypalsdk.Amount{ 202 | Total: formatAmount(amount), 203 | Currency: currency, 204 | } 205 | ref, err := p.client.RefundSale(transactionID, amt) 206 | if err != nil { 207 | return "", err 208 | } 209 | return ref.ID, nil 210 | } 211 | 212 | func (p *paypalPaymentProvider) NewPreauthorizer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Preauthorizer, error) { 213 | config := gcontext.GetConfig(ctx) 214 | return func(amount uint64, currency string, description string) (*payments.PreauthorizationResult, error) { 215 | return p.preauthorize(config, amount, currency, description) 216 | }, nil 217 | } 218 | 219 | func (p *paypalPaymentProvider) preauthorize(config *conf.Configuration, amount uint64, currency string, description string) (*payments.PreauthorizationResult, error) { 220 | profile, err := p.getExperience() 221 | if err != nil { 222 | return nil, errors.Wrap(err, "error creating paypal experience") 223 | } 224 | 225 | redirectURI := config.SiteURL + "/gocommerce/paypal" 226 | cancelURI := config.SiteURL + "/gocommerce/paypal/cancel" 227 | paymentResult, err := p.client.CreatePayment(paypalsdk.Payment{ 228 | Intent: "sale", 229 | Payer: &paypalsdk.Payer{ 230 | PaymentMethod: "paypal", 231 | }, 232 | ExperienceProfileID: profile.ID, 233 | Transactions: []paypalsdk.Transaction{paypalsdk.Transaction{ 234 | Amount: &paypalsdk.Amount{ 235 | Total: formatAmount(amount), 236 | Currency: currency, 237 | }, 238 | Description: description, 239 | }}, 240 | RedirectURLs: &paypalsdk.RedirectURLs{ 241 | ReturnURL: redirectURI, 242 | CancelURL: cancelURI, 243 | }, 244 | }) 245 | 246 | if err != nil { 247 | return nil, errors.Wrap(err, "error creating paypal payment") 248 | } 249 | return &payments.PreauthorizationResult{ 250 | ID: paymentResult.ID, 251 | }, nil 252 | } 253 | 254 | func (p *paypalPaymentProvider) getExperience() (*paypalsdk.WebProfile, error) { 255 | p.profileMutex.Lock() 256 | defer p.profileMutex.Unlock() 257 | 258 | if p.profile != nil { 259 | return p.profile, nil 260 | } 261 | 262 | profile, err := p.client.CreateWebProfile(paypalsdk.WebProfile{ 263 | Name: "gocommerce-" + uuid.NewRandom().String(), 264 | Temporary: true, 265 | InputFields: paypalsdk.InputFields{ 266 | NoShipping: 1, 267 | }, 268 | }) 269 | 270 | if err != nil { 271 | return nil, errors.Wrap(err, "failed creating web profile") 272 | } 273 | 274 | p.profile = profile 275 | return profile, nil 276 | } 277 | 278 | func formatAmount(amount uint64) string { 279 | return strconv.FormatFloat(float64(amount)/100, 'f', 2, 64) 280 | } 281 | 282 | func (p *paypalPaymentProvider) NewConfirmer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Confirmer, error) { 283 | return nil, errors.New("Paypal does not provide manual 2-step confirmation") 284 | } 285 | -------------------------------------------------------------------------------- /payments/stripe/stripe.go: -------------------------------------------------------------------------------- 1 | package stripe 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | 8 | "encoding/json" 9 | 10 | "github.com/netlify/gocommerce/models" 11 | "github.com/netlify/gocommerce/payments" 12 | "github.com/pkg/errors" 13 | "github.com/sirupsen/logrus" 14 | stripe "github.com/stripe/stripe-go" 15 | "github.com/stripe/stripe-go/client" 16 | ) 17 | 18 | type stripePaymentProvider struct { 19 | client *client.API 20 | } 21 | 22 | type stripeBodyParams struct { 23 | StripeToken string `json:"stripe_token"` 24 | StripePaymentMethodID string `json:"stripe_payment_method_id"` 25 | } 26 | 27 | // Config contains the Stripe-specific configuration for payment providers. 28 | type Config struct { 29 | SecretKey string `mapstructure:"secret_key" json:"secret_key"` 30 | } 31 | 32 | // NewPaymentProvider creates a new Stripe payment provider using the provided configuration. 33 | func NewPaymentProvider(config Config) (payments.Provider, error) { 34 | if config.SecretKey == "" { 35 | return nil, errors.New("Stripe configuration missing secret_key") 36 | } 37 | 38 | s := stripePaymentProvider{ 39 | client: &client.API{}, 40 | } 41 | s.client.Init(config.SecretKey, nil) 42 | return &s, nil 43 | } 44 | 45 | func (s *stripePaymentProvider) Name() string { 46 | return payments.StripeProvider 47 | } 48 | 49 | func (s *stripePaymentProvider) NewCharger(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Charger, error) { 50 | var bp stripeBodyParams 51 | bod, err := r.GetBody() 52 | if err != nil { 53 | return nil, err 54 | } 55 | err = json.NewDecoder(bod).Decode(&bp) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | if bp.StripePaymentMethodID == "" { 61 | return nil, errors.New("Stripe requires a stripe_payment_method_id for creating a payment intent") 62 | } 63 | return func(amount uint64, currency string, order *models.Order, invoiceNumber int64) (string, error) { 64 | return s.chargePaymentIntent(bp.StripePaymentMethodID, amount, currency, order, invoiceNumber) 65 | }, nil 66 | } 67 | 68 | func prepareShippingAddress(addr models.Address) *stripe.ShippingDetailsParams { 69 | return &stripe.ShippingDetailsParams{ 70 | Address: &stripe.AddressParams{ 71 | Line1: &addr.Address1, 72 | Line2: &addr.Address2, 73 | City: &addr.City, 74 | State: &addr.State, 75 | PostalCode: &addr.Zip, 76 | Country: &addr.Country, 77 | }, 78 | Name: &addr.Name, 79 | } 80 | } 81 | 82 | func (s *stripePaymentProvider) chargePaymentIntent(paymentMethodID string, amount uint64, currency string, order *models.Order, invoiceNumber int64) (string, error) { 83 | params := &stripe.PaymentIntentParams{ 84 | PaymentMethod: stripe.String(paymentMethodID), 85 | Amount: stripe.Int64(int64(amount)), 86 | Currency: stripe.String(currency), 87 | Description: stripe.String(fmt.Sprintf("Invoice No. %d", invoiceNumber)), 88 | Shipping: prepareShippingAddress(order.ShippingAddress), 89 | Params: stripe.Params{ 90 | Metadata: map[string]string{ 91 | "order_id": order.ID, 92 | "invoice_number": fmt.Sprintf("%d", invoiceNumber), 93 | }, 94 | }, 95 | ConfirmationMethod: stripe.String(string( 96 | stripe.PaymentIntentConfirmationMethodManual, 97 | )), 98 | Confirm: stripe.Bool(true), 99 | } 100 | intent, err := s.client.PaymentIntents.New(params) 101 | if err != nil { 102 | return "", err 103 | } 104 | 105 | if intent.Status == stripe.PaymentIntentStatusRequiresAction { 106 | return intent.ID, payments.NewPaymentPendingError(map[string]interface{}{ 107 | "payment_intent_secret": intent.ClientSecret, 108 | }) 109 | } 110 | 111 | if intent.Status == stripe.PaymentIntentStatusSucceeded { 112 | return intent.ID, nil 113 | } 114 | 115 | return "", fmt.Errorf("Invalid PaymentIntent status: %s", intent.Status) 116 | } 117 | 118 | func (s *stripePaymentProvider) NewRefunder(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Refunder, error) { 119 | return s.refund, nil 120 | } 121 | 122 | func (s *stripePaymentProvider) refund(transactionID string, amount uint64, currency string) (string, error) { 123 | stripeAmount := int64(amount) 124 | ref, err := s.client.Refunds.New(&stripe.RefundParams{ 125 | Charge: &transactionID, 126 | Amount: &stripeAmount, 127 | }) 128 | if err != nil { 129 | return "", err 130 | } 131 | 132 | return ref.ID, err 133 | } 134 | 135 | func (s *stripePaymentProvider) NewPreauthorizer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Preauthorizer, error) { 136 | return nil, errors.New("Stripe does not require preauthorization") 137 | } 138 | 139 | func (s *stripePaymentProvider) NewConfirmer(ctx context.Context, r *http.Request, log logrus.FieldLogger) (payments.Confirmer, error) { 140 | return s.confirm, nil 141 | } 142 | 143 | func (s *stripePaymentProvider) confirm(paymentID string) error { 144 | _, err := s.client.PaymentIntents.Confirm(paymentID, nil) 145 | 146 | if stripeErr, ok := err.(*stripe.Error); ok { 147 | return payments.NewPaymentConfirmFailError(stripeErr.Msg) 148 | } 149 | 150 | return err 151 | } 152 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/netlify/gocommerce/fc6215a8af06b407e94e2f6680f6c2b5a82a4278/www/index.html --------------------------------------------------------------------------------