diff --git a/.gitignore b/.gitignore index fc4f015..0ca8533 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ jobsika postgres-data .env +.opencollective-env #node node_modules diff --git a/backend/.opencollective-env-example b/backend/.opencollective-env-example new file mode 100644 index 0000000..8c7728d --- /dev/null +++ b/backend/.opencollective-env-example @@ -0,0 +1,3 @@ +OPEN_COLLECTIVE_API_URL=https://api.opencollective.com/graphql/v2 +OPEN_COLLECTIVE_API_KEY= +OPEN_COLLECTIVE_ORG_SLUG= diff --git a/backend/Makefile b/backend/Makefile index f025b99..ccd0b9a 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -29,18 +29,18 @@ build: ## run: run the api .PHONY: run run: - go run . + ENVIRONMENT=development go run . ## run4test: run the api in test environment (fixes timeouts) .PHONY: run4test run4test: - ENVIRONMENT=test go run . + ENVIRONMENT=development TEST=true go run . ## e2etest: run end to end tests against local api -## : run the api using `ENVIRONMENT=dev make run` or `make run4test` to avoid timeouts +## : run the api using `ENVIRONMENT=development TEST=true make run` or `make run4test` to avoid timeouts .PHONY: e2etest e2etest: $(E2ETEST_DEPS) - -rm -rf postgres-data; cd ./e2etests/ && API_HOST="http://localhost:7000/" npm test + -cd ./e2etests/ && API_HOST="http://localhost:7000/" npm test @make reset-postgres > /dev/null ## e2etest-compose: run end to end tests in the docker-compose.test.yaml diff --git a/backend/doc/payment.md b/backend/doc/payment.md new file mode 100644 index 0000000..37d945d --- /dev/null +++ b/backend/doc/payment.md @@ -0,0 +1,72 @@ +# Open collective payment + +The payment method we use for jobsika is based off of the opencollective [GraphQL API](https://graphql-docs-v2.opencollective.com/welcome). +We are using opencollective because the money that we receive lands directly on our opencollective account. +Which makes it easier to access that money and spend it in the open. + +### How to develop ? + +You are a developer and you would like to test or modify our payment solution. You are at the right the place, +this document aims to provide you enough information to get you started. + +#### Prerequisites + +In order to contribute to the payment solution you will need to have an account to opencollective, and have admin +access to an organisation. + +You can request admin access to [openbilling](https://opencollective.com/openbilling), [osscameroon](https://opencollective.com/osscameroon) or create your own organisation. + + +Once you have admin access to an organisation, you need to generate an API Key. To do so, go to your personal settings on opencollective select the `For developers` section and create a new personal token. +Make sure you to set the `Account` and `Webhooks` scopes for that personal token. + +**For developers tab** + +![For developers](./res/opencollective-for-developers.png) + +**Create a new token** + +![Create a new token](./res/create-a-new-token.png) + + +**Copy your personal token** + +![Copy your personal token](./res/copy-your-personal-token.png) + + +#### Setup + +Once you have your personal token, clone the [jobsika](https://github.com/osscameroon/jobsika) repository and head to the `./backend` folder. +Copy the `.opencollective-env-example` and rename it to `.opencollective-env`. Then replace the `OPENCOLLECTIVE_API_KEY` with your personal token. +and the `OPENCOLLECTIVE_ORG_SLUG` with the slug of the organisation you have admin access to. + +Now you should be able to run the backend and make payment requests. + +Run the api using `make start-api` + +Then run the command `curl -vsS -X POST -H 'Content-Type: application/json' -d '{"email":"test@email.com", "tier": "je suis con", "job_offer_id": "1"}' http://localhost:7000/pay | jq` + +You should have a similar output to: + +``` +{ + "tier_url": "https://opencollective.com/osscameroon/contribute/jobsika-joboffer-56908" +} +``` + +The `tier_url` is the link to the newly created payment tier. + + +Before you can proceed to the next step you must setup a `Webhook`. Go to the organisation profile and select the `Webhooks` tab. +Then click on `Add a new webhook` and fill the form as shown bellow. + +You will need to expose your local api to the internet. You can use [ngrok](https://ngrok.com/) to do so. + +Once you have generated a public url, you can set the URL field to `https:///open-collective-webhook`. +The `/open-collective-webhook` is the endpoint that the api exposes to receive webhooks from opencollective. + +**Webhook form** +[![Webhook form](./res/webhook-form.png)](./res/webhook-form.png) + + +Now you can go back to the `tier_url` your received from the previous payment request and click on the `Pay now` button. diff --git a/backend/doc/res/copy-your-personal-token.png b/backend/doc/res/copy-your-personal-token.png new file mode 100644 index 0000000..7fcca08 Binary files /dev/null and b/backend/doc/res/copy-your-personal-token.png differ diff --git a/backend/doc/res/create-a-new-token.png b/backend/doc/res/create-a-new-token.png new file mode 100644 index 0000000..c8fa719 Binary files /dev/null and b/backend/doc/res/create-a-new-token.png differ diff --git a/backend/doc/res/opencollective-for-developers.png b/backend/doc/res/opencollective-for-developers.png new file mode 100644 index 0000000..acf66df Binary files /dev/null and b/backend/doc/res/opencollective-for-developers.png differ diff --git a/backend/doc/res/webhook-form.png b/backend/doc/res/webhook-form.png new file mode 100644 index 0000000..48f78ee Binary files /dev/null and b/backend/doc/res/webhook-form.png differ diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index bee23d1..f6283b1 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -19,7 +19,8 @@ services: env_file: - ./.docker-env environment: - - ENVIRONMENT=test + - TEST=true + - ENVIRONMENT=development volumes: - .:/api ports: diff --git a/backend/e2etests/pay.test.after.js b/backend/e2etests/pay.test.after.js new file mode 100644 index 0000000..9265611 --- /dev/null +++ b/backend/e2etests/pay.test.after.js @@ -0,0 +1,46 @@ +import { expect } from "chai"; +import request from "supertest"; +import dotenv from "dotenv"; + +dotenv.config(); +const apiHost = process.env.API_HOST; +const endpoint = "pay"; + +describe(`${endpoint}`, function () { + describe("POST", function () { + it("fails to make a payment with invalid email address", async function () { + return request(apiHost) + .post(`${endpoint}`) + .set("Accept", "application/json") + .send({ + email: "wrong email", + tier: "new jobsika tier", + job_offer_id: "1", + }) + .expect(400) + .expect("Content-Type", "application/json; charset=utf-8") + .then((res) => { + expect(JSON.stringify(res.body)).contain("email field is invalid"); + }); + }); + + it("proceed with a payment", async function () { + return request(apiHost) + .post(`${endpoint}`) + .set("Accept", "application/json") + .send({ + email: "test@email.com", + tier: "new jobsika tier", + job_offer_id: "1", + }) + .expect(200) + .expect("Content-Type", "application/json; charset=utf-8") + .then((res) => { + expect(JSON.stringify(res.body)).contain( + "opencollective.com" + ); + }); + }); + + }); +}); diff --git a/backend/go.mod b/backend/go.mod index aad947b..0219925 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,8 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/kr/text v0.2.0 // indirect github.com/lib/pq v1.10.4 // indirect + github.com/machinebox/graphql v0.2.2 + github.com/matryer/is v1.4.1 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect @@ -18,6 +20,7 @@ require ( github.com/sirupsen/logrus v1.8.1 golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce // indirect golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 // indirect + golang.org/x/text v0.3.7 google.golang.org/protobuf v1.27.1 // indirect gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index c93a17a..8afce6b 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -117,6 +117,10 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk= github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/machinebox/graphql v0.2.2 h1:dWKpJligYKhYKO5A2gvNhkJdQMNZeChZYyBbrZkBZfo= +github.com/machinebox/graphql v0.2.2/go.mod h1:F+kbVMHuwrQ5tYgU9JXlnskM8nOaFxCAEolaQybkjWA= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1092dd8..626ec93 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,11 +4,13 @@ import ( "os" "github.com/joho/godotenv" + "github.com/osscameroon/jobsika/internal/payment" "github.com/osscameroon/jobsika/internal/storage" ) var ( - postgresEnvFile = ".postgres-env" + postgresEnvFile = ".postgres-env" + openCollectiveEnvFile = ".opencollective-env" //StageEnv stage environment StageEnv = "stage" //ProdEnv production environment @@ -23,6 +25,9 @@ type Config struct { //Environment can be set to stage or production Environment string JWTKey string + + //OCOpts contains the open collective options + OCOpts payment.OpenCollectiveOptions } // GetDefaultConfig returns a config with default values and env variables @@ -30,6 +35,7 @@ func GetDefaultConfig() Config { if defaultConfig == nil { //load postgres env variables godotenv.Load(postgresEnvFile) + godotenv.Load(openCollectiveEnvFile) defaultConfig = &Config{ DBOpts: storage.DBOptions{ @@ -41,6 +47,11 @@ func GetDefaultConfig() Config { }, Environment: os.Getenv("ENVIRONMENT"), JWTKey: os.Getenv("JWT_KEY"), + OCOpts: payment.OpenCollectiveOptions{ + URL: os.Getenv("OPEN_COLLECTIVE_API_URL"), + KEY: os.Getenv("OPEN_COLLECTIVE_API_KEY"), + OrgSlug: os.Getenv("OPEN_COLLECTIVE_ORG_SLUG"), + }, } } diff --git a/backend/internal/graphql/graphql.go b/backend/internal/graphql/graphql.go new file mode 100644 index 0000000..7085df1 --- /dev/null +++ b/backend/internal/graphql/graphql.go @@ -0,0 +1,50 @@ +package graphql + +import ( + "context" + + "github.com/machinebox/graphql" +) + +// IClient is the interface for the client +type IClient interface { + Run(req *graphql.Request, variables map[string]interface{}, response interface{}) error +} + +// Client is the client +type Client struct { + c *graphql.Client + key string +} + +// Run query data from the graphql client +func (c *Client) Run(req *graphql.Request, variables map[string]interface{}, response interface{}) error { + req.Header.Set("Api-Key", c.key) + + //set variables + for k, v := range variables { + req.Var(k, v) + } + + ctx := context.Background() + if err := c.c.Run(ctx, req, response); err != nil { + return err + } + + return nil +} + +// Query converts a string into a graphql.Request +func Query(q string) *graphql.Request { + return graphql.NewRequest(q) +} + +// NewClient creates a new client +func NewClient(url, key string) *Client { + client := &Client{ + c: graphql.NewClient(url), + key: key, + } + + return client +} diff --git a/backend/internal/handlers/opencollective_handler.go b/backend/internal/handlers/opencollective_handler.go new file mode 100644 index 0000000..d5156b4 --- /dev/null +++ b/backend/internal/handlers/opencollective_handler.go @@ -0,0 +1,170 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/osscameroon/jobsika/internal/server" + log "github.com/sirupsen/logrus" +) + +type WebhookPayload struct { + CreatedAt time.Time `json:"createdAt"` + ID int `json:"id"` + CollectiveID int `json:"CollectiveId"` + Type string `json:"type"` + Data struct { + FromCollective struct { + ID int `json:"id"` + Type string `json:"type"` + Slug string `json:"slug"` + Name string `json:"name"` + TwitterHandle string `json:"twitterHandle"` + GithubHandle string `json:"githubHandle"` + RepositoryURL string `json:"repositoryUrl"` + Image string `json:"image"` + } `json:"fromCollective"` + Collective struct { + ID int `json:"id"` + Type string `json:"type"` + Slug string `json:"slug"` + Name string `json:"name"` + TwitterHandle interface{} `json:"twitterHandle"` + GithubHandle string `json:"githubHandle"` + RepositoryURL string `json:"repositoryUrl"` + Image string `json:"image"` + } `json:"collective"` + Transaction struct { + ID int `json:"id"` + Kind string `json:"kind"` + Type string `json:"type"` + UUID string `json:"uuid"` + Group string `json:"group"` + Amount int `json:"amount"` + IsDebt bool `json:"isDebt"` + OrderID int64 `json:"OrderId"` + Currency string `json:"currency"` + IsRefund bool `json:"isRefund"` + ExpenseID interface{} `json:"ExpenseId"` + CreatedAt time.Time `json:"createdAt"` + TaxAmount interface{} `json:"taxAmount"` + Description string `json:"description"` + CollectiveID int `json:"CollectiveId"` + HostCurrency string `json:"hostCurrency"` + CreatedByUserID int `json:"CreatedByUserId"` + FromCollectiveID int `json:"FromCollectiveId"` + AmountInHostCurrency int `json:"amountInHostCurrency"` + HostFeeInHostCurrency int `json:"hostFeeInHostCurrency"` + NetAmountInHostCurrency int `json:"netAmountInHostCurrency"` + PlatformFeeInHostCurrency int `json:"platformFeeInHostCurrency"` + UsingGiftCardFromCollectiveID interface{} `json:"UsingGiftCardFromCollectiveId"` + NetAmountInCollectiveCurrency int `json:"netAmountInCollectiveCurrency"` + AmountSentToHostInHostCurrency int `json:"amountSentToHostInHostCurrency"` + PaymentProcessorFeeInHostCurrency int `json:"paymentProcessorFeeInHostCurrency"` + FormattedAmount string `json:"formattedAmount"` + FormattedAmountWithInterval string `json:"formattedAmountWithInterval"` + } `json:"transaction"` + } `json:"data"` +} + +func OpenCollectiveWebhook(c *gin.Context) { + var payload WebhookPayload + err := c.BindJSON(&payload) + if err != nil { + log.Error(err) + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + paymentClient, err := server.GetDefaultPaymentClient() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "could not create payment client"}) + return + } + response, err := paymentClient.GetOrder(payload.Data.Transaction.OrderID) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "could not create payment client"}) + return + } + + db, err := server.GetDefaultDBClient() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to get db client"}) + return + } + + tierID := response.Order.Tier.LegacyID + _, err = db.GetPaymentRecordByID(tierID) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to get payment record"}) + return + } + + if err := paymentClient.DeleteTier(tierID); err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to delete tier"}) + return + } + + log.Info("OpenCollectiveWebhook was triggered!") +} + +// GetOrderID retrieves an open collective order id +// This endpoint was created for testing purposes +func GetOrderID(c *gin.Context) { + orderID, err := strconv.ParseInt(c.Query("orderID"), 10, 64) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to parse order id"}) + return + } + + paymentClient, err := server.GetDefaultPaymentClient() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "could not create payment client"}) + return + } + response, err := paymentClient.GetOrder(orderID) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "could not create payment client"}) + return + } + + db, err := server.GetDefaultDBClient() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to get db client"}) + return + } + + tierID := response.Order.Tier.LegacyID + paymentRecord, err := db.GetPaymentRecordByID(tierID) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to get payment record"}) + return + } + fmt.Printf("paymentRecord: %+v\n", paymentRecord) + + fmt.Printf("legacyID: %d\n", response.Order.Tier.LegacyID) + log.Info("GetOrders was triggered!") +} diff --git a/backend/internal/handlers/pay_handler.go b/backend/internal/handlers/pay_handler.go new file mode 100644 index 0000000..60985f0 --- /dev/null +++ b/backend/internal/handlers/pay_handler.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/osscameroon/jobsika/internal/server" + "github.com/osscameroon/jobsika/pkg/models/v1beta" + log "github.com/sirupsen/logrus" +) + +//PostPay handler for POST +func PostPay(c *gin.Context) { + contentType := c.Request.Header.Get("Content-Type") + if !strings.Contains(contentType, "application/json") { + log.Error(errors.New("Wrong contentType")) + c.JSON(http.StatusBadRequest, + gin.H{"error": "could not post rating"}) + return + } + + query := v1beta.PayPostQuery{} + if err := c.ShouldBind(&query); err != nil { + log.Error(err) + c.JSON(http.StatusBadRequest, + gin.H{"error": "could not post rating"}) + return + } + + if err := query.Validate(); err != nil { + log.Error(err) + c.JSON(http.StatusBadRequest, + gin.H{"error": err.Error()}) + return + } + + //We can't proceed with a payment on test mode as this will create unecessary + //payment on opencollective + if os.Getenv("TEST") == "true" { + //Send the link to the newly created opencollective tier to back to the client + c.JSON(http.StatusOK, gin.H{"tier_url": "https://opencollective.com/osscameroon/something-something"}) + return + } + + //Create a new opencollective tier + //The deletion should happen on the webhook or a day after created + paymentClient, err := server.GetDefaultPaymentClient() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "could not create payment"}) + return + } + response, err := paymentClient.CreateTier() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "could not create payment"}) + return + } + + //Record customer payment request + db, err := server.GetDefaultDBClient() + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to get db client"}) + return + } + + jobOfferID, err := strconv.ParseInt(query.JobOfferID, 10, 64) + if err != nil { + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to parse job offer id"}) + return + } + + url := fmt.Sprintf("%s%s-%d", paymentClient.GetContributionURL(), response.CreateTier.Slug, response.CreateTier.LegacyID) + err = db.CreatePaymentRecord(&v1beta.PaymentRecord{ + TierId: response.CreateTier.ID, + JobOfferID: jobOfferID, + LegacyId: response.CreateTier.LegacyID, + Slug: response.CreateTier.Slug, + Email: query.Email, + TierUrl: url, + }) + if err != nil { + retry := 0 + for retry < 3 { + if err := paymentClient.DeleteTier(response.CreateTier.LegacyID); err != nil { + retry++ + time.Sleep(time.Duration(retry*100) * time.Millisecond) + } else { + break + } + } + + log.Error(err) + c.JSON(http.StatusInternalServerError, + gin.H{"error": "failed to create payment record"}) + return + } + + //Send the link to the newly created opencollective tier to back to the client + c.JSON(http.StatusOK, gin.H{"tier_url": url}) +} diff --git a/backend/internal/handlers/subscribers_handlers.go b/backend/internal/handlers/subscribers_handlers.go index fa6a58e..fde7041 100644 --- a/backend/internal/handlers/subscribers_handlers.go +++ b/backend/internal/handlers/subscribers_handlers.go @@ -5,9 +5,9 @@ import ( "net/http" "strings" + "github.com/gin-gonic/gin" "github.com/osscameroon/jobsika/internal/server" "github.com/osscameroon/jobsika/pkg/models/v1beta" - "github.com/gin-gonic/gin" log "github.com/sirupsen/logrus" ) diff --git a/backend/internal/payment/payment.go b/backend/internal/payment/payment.go new file mode 100644 index 0000000..4f61a8a --- /dev/null +++ b/backend/internal/payment/payment.go @@ -0,0 +1,347 @@ +package payment + +import ( + "fmt" + "os" + "time" + + "github.com/osscameroon/jobsika/internal/graphql" +) + +type IClient interface { + CreateTier() error + DeleteTier() error +} + +const TierDescription = ` +You are about to pay %.2f EUR for a job offer on jobsika.com. + +Please proceed to payment and once done, you will receive an email with the job offer details. +` + +const OSSCAMEROON_SLUG = "osscameroon" +const OPEN_COLLECTIVE_CONTRIBUTE = "https://opencollective.com/%s/contribute/" + +// PostTierResponse +type PostTierResponse struct { + CreateTier struct { + ID string `json:"id"` + LegacyID int64 `json:"legacyId"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Amount struct { + Value float64 `json:"value"` + Currency string `json:"currency"` + ValueInCents int `json:"valueInCents"` + } `json:"amount"` + Button interface{} `json:"button"` + Goal struct { + Value interface{} `json:"value"` + Currency string `json:"currency"` + ValueInCents interface{} `json:"valueInCents"` + } `json:"goal"` + Type string `json:"type"` + Interval string `json:"interval"` + Frequency string `json:"frequency"` + Presets interface{} `json:"presets"` + MaxQuantity int `json:"maxQuantity"` + AvailableQuantity int `json:"availableQuantity"` + CustomFields interface{} `json:"customFields"` + AmountType string `json:"amountType"` + MinimumAmount struct { + Value interface{} `json:"value"` + Currency string `json:"currency"` + ValueInCents interface{} `json:"valueInCents"` + } `json:"minimumAmount"` + EndsAt interface{} `json:"endsAt"` + InvoiceTemplate string `json:"invoiceTemplate"` + UseStandalonePage bool `json:"useStandalonePage"` + SingleTicket bool `json:"singleTicket"` + } `json:"createTier"` +} + +//create a struct +type Client struct { + graphQLClient *graphql.Client + orgSlug string +} + +type OpenCollectiveOptions struct { + URL string + KEY string + OrgSlug string +} + +func NewClient(opts OpenCollectiveOptions) (*Client, error) { + client := graphql.NewClient(opts.URL, opts.KEY) + if client == nil { + return nil, fmt.Errorf("failed to create graphql client") + } + + orgSlug := opts.OrgSlug + if orgSlug == "" { + orgSlug = OSSCAMEROON_SLUG + } + + return &Client{ + graphQLClient: client, + orgSlug: orgSlug, + }, nil +} + +func (c Client) GetContributionURL() string { + return fmt.Sprintf(OPEN_COLLECTIVE_CONTRIBUTE, c.orgSlug) +} + +func (c Client) CreateTier() (PostTierResponse, error) { + query := graphql.Query(` +mutation ( + $tier: TierCreateInput! + $account: AccountReferenceInput! + ) { + createTier(tier: $tier, account: $account) { + id + legacyId + slug + name + description + amount { + value + currency + valueInCents + } + button + goal { + value + currency + valueInCents + } + type + interval + frequency + presets + maxQuantity + availableQuantity + customFields + amountType + minimumAmount { + value + currency + valueInCents + } + endsAt + invoiceTemplate + useStandalonePage + singleTicket + } + } +`) + + price := 5.0 + name := "jobsika-joboffer" + if os.Getenv("ENVIRONMENT") == "development" { + price = 0.01 + name = "test-jobsika-joboffer" + } + variables := map[string]interface{}{ + "tier": map[string]interface{}{ + "amount": map[string]interface{}{ + "value": price, + "currency": "EUR", + }, + "name": name, + "description": fmt.Sprintf(TierDescription, price), + "button": "PAY NOW", + "type": "PRODUCT", + "amountType": "FIXED", + "frequency": "ONETIME", + "maxQuantity": 1, + "useStandalonePage": true, + }, + "account": map[string]interface{}{ + "slug": OSSCAMEROON_SLUG, + }, + } + + response := PostTierResponse{} + + if err := c.graphQLClient.Run(query, variables, &response); err != nil { + return PostTierResponse{}, err + } + + return response, nil +} + +// DeleteTierResponse +type DeleteTierResponse struct { + DeleteTier struct { + ID string `json:"id"` + LegacyID int `json:"legacyId"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Amount struct { + Value float64 `json:"value"` + Currency string `json:"currency"` + ValueInCents int `json:"valueInCents"` + } `json:"amount"` + Button interface{} `json:"button"` + Goal struct { + Value interface{} `json:"value"` + Currency string `json:"currency"` + ValueInCents interface{} `json:"valueInCents"` + } `json:"goal"` + Type string `json:"type"` + Interval string `json:"interval"` + Frequency string `json:"frequency"` + Presets interface{} `json:"presets"` + MaxQuantity int `json:"maxQuantity"` + AvailableQuantity interface{} `json:"availableQuantity"` + CustomFields interface{} `json:"customFields"` + AmountType string `json:"amountType"` + MinimumAmount struct { + Value interface{} `json:"value"` + Currency string `json:"currency"` + ValueInCents interface{} `json:"valueInCents"` + } `json:"minimumAmount"` + EndsAt interface{} `json:"endsAt"` + InvoiceTemplate string `json:"invoiceTemplate"` + UseStandalonePage bool `json:"useStandalonePage"` + SingleTicket bool `json:"singleTicket"` + } `json:"deleteTier"` +} + +func (c Client) DeleteTier(legacyId int64) error { + query := graphql.Query(` + mutation ( + $tier: TierReferenceInput! + ) { + deleteTier( + tier: $tier + ) { + id + legacyId + slug + name + description + amount { + value + currency + valueInCents + } + button + goal { + value + currency + valueInCents + } + type + interval + frequency + presets + maxQuantity + availableQuantity + customFields + amountType + minimumAmount { + value + currency + valueInCents + } + endsAt + invoiceTemplate + useStandalonePage + singleTicket + } + } +`) + + variables := map[string]interface{}{ + "tier": map[string]interface{}{ + "legacyId": legacyId, + }, + } + + response := DeleteTierResponse{} + if err := c.graphQLClient.Run(query, variables, &response); err != nil { + return err + } + + return nil +} + +// GetOder +type GetOrderResponse struct { + Order struct { + ID string `json:"id"` + LegacyID int `json:"legacyId"` + Tier struct { + ID string `json:"id"` + LegacyID int64 `json:"legacyId"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Button string `json:"button"` + Type string `json:"type"` + Interval string `json:"interval"` + Frequency string `json:"frequency"` + Presets []int `json:"presets"` + MaxQuantity int `json:"maxQuantity"` + AvailableQuantity int `json:"availableQuantity"` + CustomFields struct { + } `json:"customFields"` + AmountType string `json:"amountType"` + EndsAt time.Time `json:"endsAt"` + InvoiceTemplate string `json:"invoiceTemplate"` + UseStandalonePage bool `json:"useStandalonePage"` + SingleTicket bool `json:"singleTicket"` + } `json:"tier"` + } `json:"order"` +} + +func (c Client) GetOrder(orderID int64) (GetOrderResponse, error) { + query := graphql.Query(` +query ( + $order: OrderReferenceInput! + ) { + order(order: $order) { + id + legacyId + tier { + id + legacyId + slug + name + description + button + type + interval + frequency + presets + maxQuantity + availableQuantity + customFields + amountType + endsAt + invoiceTemplate + useStandalonePage + singleTicket + } + } +} +`) + + variables := map[string]interface{}{ + "order": map[string]interface{}{ + "legacyId": orderID, + }, + } + + response := GetOrderResponse{} + if err := c.graphQLClient.Run(query, variables, &response); err != nil { + return GetOrderResponse{}, err + } + + return response, nil +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index 5751d88..c3fdd9c 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -2,13 +2,15 @@ package server import ( "github.com/osscameroon/jobsika/internal/config" + "github.com/osscameroon/jobsika/internal/payment" "github.com/osscameroon/jobsika/internal/storage" ) // Server defines the api server struct type Server struct { - DB storage.DB - Conf config.Config + DB storage.DB + Conf config.Config + PaymentClient payment.Client } var defaultServer *Server @@ -22,9 +24,15 @@ func GetDefaultServer() (*Server, error) { return nil, err } + paymentClient, err := payment.NewClient(conf.OCOpts) + if err != nil { + return nil, err + } + defaultServer = &Server{ - DB: *db, - Conf: conf, + DB: *db, + Conf: conf, + PaymentClient: *paymentClient, } } @@ -50,3 +58,13 @@ func GetDefaultConfig() config.Config { return s.Conf } + +// GetDefaultPaymentClient returns the default payment client +func GetDefaultPaymentClient() (payment.Client, error) { + s, err := GetDefaultServer() + if err != nil { + return payment.Client{}, err + } + + return s.PaymentClient, nil +} diff --git a/backend/internal/storage/cities.go b/backend/internal/storage/cities.go index 41ef1a0..1fed0b6 100644 --- a/backend/internal/storage/cities.go +++ b/backend/internal/storage/cities.go @@ -1,8 +1,9 @@ package storage import ( - "github.com/osscameroon/jobsika/pkg/models/v1beta" "os" + + "github.com/osscameroon/jobsika/pkg/models/v1beta" ) // GetCities get cities @@ -21,7 +22,7 @@ func (db DB) GetCities() ([]string, error) { if defaultCity.Name == "" { tmpCities := DefaultCities //If we are running tests we use a short list of cities - if os.Getenv("ENVIRONMENT") == "test" { + if os.Getenv("TEST") == "true" { tmpCities = []string{"Tester"} } diff --git a/backend/internal/storage/companies.go b/backend/internal/storage/companies.go index d4f9cd6..6c10bb1 100644 --- a/backend/internal/storage/companies.go +++ b/backend/internal/storage/companies.go @@ -21,7 +21,7 @@ func (db DB) GetCompanies() ([]v1beta.Company, error) { if cameroonianCompany.Name == "" { tmpCameroonianCompanies := CameroonianCompanies //If we are running tests we use a short list of companies - if os.Getenv("ENVIRONMENT") == "test" { + if os.Getenv("TEST") == "true" { tmpCameroonianCompanies = []string{"Tester"} } diff --git a/backend/internal/storage/jobtitles.go b/backend/internal/storage/jobtitles.go index e3b1b7d..9f8b815 100644 --- a/backend/internal/storage/jobtitles.go +++ b/backend/internal/storage/jobtitles.go @@ -1,9 +1,10 @@ package storage import ( - "gorm.io/gorm" "os" + "gorm.io/gorm" + "github.com/osscameroon/jobsika/pkg/models/v1beta" ) @@ -23,7 +24,7 @@ func (db DB) GetJobTitles() ([]string, error) { if defaultJobTitle.Title == "" { tmpJobTitles := DefaultJobTitles //If we are running tests we use a short list of jobtitles - if os.Getenv("ENVIRONMENT") == "test" { + if os.Getenv("TEST") == "true" { tmpJobTitles = []string{"Tester"} } diff --git a/backend/internal/storage/payment_record.go b/backend/internal/storage/payment_record.go new file mode 100644 index 0000000..16b94fd --- /dev/null +++ b/backend/internal/storage/payment_record.go @@ -0,0 +1,18 @@ +package storage + +import "github.com/osscameroon/jobsika/pkg/models/v1beta" + +func (db *DB) CreatePaymentRecord(record *v1beta.PaymentRecord) error { + return db.c.Create(record).Error +} + +// GetPaymentRecordByID get salary by `id` +func (db DB) GetPaymentRecordByID(id int64) (v1beta.PaymentRecord, error) { + paymentRecord := v1beta.PaymentRecord{} + ret := db.c.First(&paymentRecord, "legacy_id = ?", id) + if ret.Error != nil { + return paymentRecord, ret.Error + } + + return paymentRecord, nil +} diff --git a/backend/internal/storage/ratings.go b/backend/internal/storage/ratings.go index 4bc360f..9468634 100644 --- a/backend/internal/storage/ratings.go +++ b/backend/internal/storage/ratings.go @@ -7,6 +7,9 @@ import ( "strconv" "strings" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "github.com/osscameroon/jobsika/pkg/models/v1beta" log "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -89,7 +92,7 @@ func (db DB) GetRatings(page, limit, jobtitle, company, city, seniority string) c.name = ? and (Select count(ss.id) from salaries ss - where ss.title_id = s.title_id and + where ss.title_id = s.title_id and ss.company_id = s.company_id) >= ?`, company, maxEntryBeforeDisplay) } if jobtitle != "" { @@ -155,7 +158,7 @@ func (db DB) GetAverageRating(jobtitle, company, city, seniority string) (v1beta c.name = ? and (Select count(ss.id) from salaries ss - where ss.title_id = s.title_id and + where ss.title_id = s.title_id and ss.company_id = s.company_id) >= ?`, company, maxEntryBeforeDisplay) } if jobtitle != "" { @@ -186,9 +189,10 @@ func (db DB) GetAverageRating(jobtitle, company, city, seniority string) (v1beta // PostRatings post new rating func (db DB) PostRatings(query v1beta.RatingPostQuery) error { - query.CompanyName = strings.Title(strings.ToLower(query.CompanyName)) - query.JobTitle = strings.Title(strings.ToLower(query.JobTitle)) - query.City = strings.Title(strings.ToLower(query.City)) + caser := cases.Title(language.English) + query.CompanyName = caser.String(strings.ToLower(query.CompanyName)) + query.JobTitle = caser.String(strings.ToLower(query.JobTitle)) + query.City = caser.String(strings.ToLower(query.City)) return db.c.Transaction(func(tx *gorm.DB) error { company, err := postCompany(tx, query) diff --git a/backend/main.go b/backend/main.go index bf9c3eb..bb02250 100644 --- a/backend/main.go +++ b/backend/main.go @@ -3,8 +3,8 @@ package main import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" - _ "github.com/osscameroon/jobsika/docs" "github.com/osscameroon/jobsika/internal/handlers" + _ "github.com/osscameroon/jobsika/swagger" ) func main() { @@ -55,6 +55,15 @@ func main() { //Subscribers router.POST("/subscribers", handlers.PostSubscribers) + //Pay + router.POST("/pay", handlers.PostPay) + + //GetOrderID + router.GET("/getorder", handlers.GetOrderID) + + //OpenCollectionWebhook + router.POST("/open-collective-webhook", handlers.OpenCollectiveWebhook) + if err := router.Run(":7000"); err != nil { return } diff --git a/backend/migrations/20230521013641_new.create_payment_record_table.sql b/backend/migrations/20230521013641_new.create_payment_record_table.sql new file mode 100644 index 0000000..8cf71c7 --- /dev/null +++ b/backend/migrations/20230521013641_new.create_payment_record_table.sql @@ -0,0 +1,23 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS payment_records ( + id BIGSERIAL NOT NULL PRIMARY KEY, + email VARCHAR(255) NOT NULL, + job_offer_id BIGINT NOT NULL, + tier_id VARCHAR(255) NOT NULL, + legacy_id INTEGER NOT NULL, + slug VARCHAR(255) NOT NULL, + tier_url VARCHAR(255) NOT NULL, + createdat DATE, + updatedat DATE, + CONSTRAINT fk_job_offers + FOREIGN KEY(job_offer_id) + REFERENCES job_offers(id) +); + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS payment_records; +-- +goose StatementEnd diff --git a/backend/pkg/models/v1beta/pay.go b/backend/pkg/models/v1beta/pay.go new file mode 100644 index 0000000..c824242 --- /dev/null +++ b/backend/pkg/models/v1beta/pay.go @@ -0,0 +1,30 @@ +package v1beta + +import ( + "errors" + "strings" +) + +type PayPostQuery struct { + // The email of the user making the payment + Email string `json:"email"` + Tier string `json:"tier"` + JobOfferID string `json:"job_offer_id"` +} + +// Validate check if the mandatory fields are filled +func (p PayPostQuery) Validate() error { + if strings.TrimSpace(p.Email) == "" { + return errors.New("email field is mandatory") + } + + if !IsEmailValid(p.Email) { + return errors.New("email field is invalid") + } + + if strings.TrimSpace(p.Tier) == "" { + return errors.New("tier is mandatory") + } + + return nil +} diff --git a/backend/pkg/models/v1beta/payment_record.go b/backend/pkg/models/v1beta/payment_record.go new file mode 100644 index 0000000..1792e07 --- /dev/null +++ b/backend/pkg/models/v1beta/payment_record.go @@ -0,0 +1,15 @@ +package v1beta + +import "time" + +type PaymentRecord struct { + Id int64 `json:"id" gorm:"column:id;auto_increment;primary_key"` + Email string `json:"email" gorm:"column:email"` + TierId string `json:"tier_id" gorm:"column:tier_id"` + JobOfferID int64 `json:"job_offer_id" gorm:"column:job_offer_id"` + LegacyId int64 `json:"legacy_id" gorm:"column:legacy_id"` + Slug string `json:"slug" gorm:"column:slug"` + TierUrl string `json:"tier_url" gorm:"column:tier_url"` + CreatedAt time.Time `json:"created_at" gorm:"column:createdat"` + UpdatedAt time.Time `json:"updated_at" gorm:"column:updatedat"` +} diff --git a/backend/docs/cities.go b/backend/swagger/cities.go similarity index 96% rename from backend/docs/cities.go rename to backend/swagger/cities.go index 6330314..d5a40f7 100644 --- a/backend/docs/cities.go +++ b/backend/swagger/cities.go @@ -1,4 +1,4 @@ -package docs +package swagger // swagger:route GET /cities cities idOfCities // Companies returns cities endpoint diff --git a/backend/docs/companies.go b/backend/swagger/companies.go similarity index 98% rename from backend/docs/companies.go rename to backend/swagger/companies.go index 1e8e913..7f69261 100644 --- a/backend/docs/companies.go +++ b/backend/swagger/companies.go @@ -1,4 +1,4 @@ -package docs +package swagger import ( "github.com/osscameroon/jobsika/pkg/models/v1beta" diff --git a/backend/docs/company_ratings.go b/backend/swagger/company_ratings.go similarity index 98% rename from backend/docs/company_ratings.go rename to backend/swagger/company_ratings.go index f4b24a3..d22fd80 100644 --- a/backend/docs/company_ratings.go +++ b/backend/swagger/company_ratings.go @@ -1,4 +1,4 @@ -package docs +package swagger import ( "github.com/osscameroon/jobsika/pkg/models/v1beta" diff --git a/backend/docs/health.go b/backend/swagger/health.go similarity index 95% rename from backend/docs/health.go rename to backend/swagger/health.go index 6c84fae..1e2eb19 100644 --- a/backend/docs/health.go +++ b/backend/swagger/health.go @@ -1,4 +1,4 @@ -package docs +package swagger // swagger:route GET /health health idOfHealth // Companies returns health endpoint diff --git a/backend/docs/jobtitles.go b/backend/swagger/jobtitles.go similarity index 97% rename from backend/docs/jobtitles.go rename to backend/swagger/jobtitles.go index e27cb80..6ea55a6 100644 --- a/backend/docs/jobtitles.go +++ b/backend/swagger/jobtitles.go @@ -1,4 +1,4 @@ -package docs +package swagger // swagger:route GET /jobtitles jobtitles idOfCompanyWithoutID // Companies returns the list of jobtitles diff --git a/backend/docs/ratings.go b/backend/swagger/ratings.go similarity index 99% rename from backend/docs/ratings.go rename to backend/swagger/ratings.go index db2b685..5380a89 100644 --- a/backend/docs/ratings.go +++ b/backend/swagger/ratings.go @@ -1,4 +1,4 @@ -package docs +package swagger import ( "github.com/osscameroon/jobsika/pkg/models/v1beta" diff --git a/backend/docs/salaries.go b/backend/swagger/salaries.go similarity index 98% rename from backend/docs/salaries.go rename to backend/swagger/salaries.go index 1c4dc45..7940feb 100644 --- a/backend/docs/salaries.go +++ b/backend/swagger/salaries.go @@ -1,4 +1,4 @@ -package docs +package swagger import ( "github.com/osscameroon/jobsika/pkg/models/v1beta" diff --git a/backend/docs/seniority.go b/backend/swagger/seniority.go similarity index 96% rename from backend/docs/seniority.go rename to backend/swagger/seniority.go index 018b50b..8d46e7e 100644 --- a/backend/docs/seniority.go +++ b/backend/swagger/seniority.go @@ -1,4 +1,4 @@ -package docs +package swagger // swagger:route GET /seniority seniority idOfSeniority // Companies returns seniority endpoint diff --git a/backend/docs/docs.go b/backend/swagger/swagger.go similarity index 95% rename from backend/docs/docs.go rename to backend/swagger/swagger.go index 437fe57..bb36f19 100644 --- a/backend/docs/docs.go +++ b/backend/swagger/swagger.go @@ -14,4 +14,4 @@ // - application/json // // swagger:meta -package docs +package swagger