Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ jobs:
CLERK_AUTHORIZED_PARTIES: https://staging.example.invalid
JUNTLY_CONTACT_ENCRYPTION_KEY: MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDE=
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: pk_test_only
STRIPE_SECRET_KEY: sk_test_ci_only
STRIPE_WEBHOOK_SECRET: whsec_ci_only
JUNTLY_PUBLIC_ORIGIN: https://staging.example.invalid
JUNTLY_PLATFORM_FEE_BPS: "1000"
run: docker compose -f compose.production.yaml config --quiet

- name: Validate operational scripts
Expand Down
9 changes: 9 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ CLERK_JWT_KEY=
CLERK_AUTHORIZED_PARTIES=http://localhost:4200
# Optional Go duration, capped at 30s. Leave empty unless a local clock cannot synchronize.
CLERK_CLOCK_SKEW=
# Stripe payments remain disabled when all fields below are empty. If any field is set,
# all required fields must be present or API startup fails closed.
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Optional test-only API override. Production uses https://api.stripe.com.
STRIPE_API_BASE=
JUNTLY_PUBLIC_ORIGIN=https://somosvila.com
# Reviewed commission in basis points (1000 = 10%). Do not derive this in the browser.
JUNTLY_PLATFORM_FEE_BPS=
35 changes: 31 additions & 4 deletions backend/cmd/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ package main

import (
"errors"
"strconv"
"strings"
"time"

"github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/contactreveal"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments"
)

var ErrInvalidRuntimeConfig = errors.New("invalid API runtime configuration")

type runtimeConfig struct {
databaseURL string
verifier authn.Verifier
contactCipher contactreveal.Cipher
databaseURL string
verifier authn.Verifier
contactCipher contactreveal.Cipher
paymentGateway payments.Gateway
platformFeeBPS int
}

func loadRuntimeConfig(lookup func(string) string) (runtimeConfig, error) {
Expand Down Expand Up @@ -43,8 +47,31 @@ func loadRuntimeConfig(lookup func(string) string) (runtimeConfig, error) {
return runtimeConfig{}, ErrInvalidRuntimeConfig
}
}
var paymentGateway payments.Gateway
platformFeeBPS := 0
stripeSecret := strings.TrimSpace(lookup("STRIPE_SECRET_KEY"))
stripeWebhook := strings.TrimSpace(lookup("STRIPE_WEBHOOK_SECRET"))
publicOrigin := strings.TrimSpace(lookup("JUNTLY_PUBLIC_ORIGIN"))
feeValue := strings.TrimSpace(lookup("JUNTLY_PLATFORM_FEE_BPS"))
if stripeSecret != "" || stripeWebhook != "" || publicOrigin != "" || feeValue != "" {
if stripeSecret == "" || stripeWebhook == "" || publicOrigin == "" || feeValue == "" {
return runtimeConfig{}, ErrInvalidRuntimeConfig
}
platformFeeBPS, err = strconv.Atoi(feeValue)
if err != nil || platformFeeBPS < 0 || platformFeeBPS >= 10_000 {
return runtimeConfig{}, ErrInvalidRuntimeConfig
}
apiBase := strings.TrimSpace(lookup("STRIPE_API_BASE"))
if apiBase == "" {
apiBase = "https://api.stripe.com"
}
paymentGateway, err = payments.NewStripeGateway(payments.StripeConfig{SecretKey: stripeSecret, WebhookSecret: stripeWebhook, APIBase: apiBase, PublicOrigin: publicOrigin, Now: time.Now})
if err != nil {
return runtimeConfig{}, ErrInvalidRuntimeConfig
}
}

return runtimeConfig{databaseURL: databaseURL, verifier: verifier, contactCipher: contactCipher}, nil
return runtimeConfig{databaseURL: databaseURL, verifier: verifier, contactCipher: contactCipher, paymentGateway: paymentGateway, platformFeeBPS: platformFeeBPS}, nil
}

func parseOptionalDuration(value string) (time.Duration, error) {
Expand Down
36 changes: 36 additions & 0 deletions backend/cmd/api/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,39 @@ func TestLoadRuntimeConfigRejectsInvalidClerkClockSkew(t *testing.T) {
})
}
}

func TestLoadRuntimeConfigEnablesStripeOnlyWithCompleteServerConfiguration(t *testing.T) {
t.Parallel()
config, err := loadRuntimeConfig(func(key string) string {
return map[string]string{
"DATABASE_URL": "postgresql://synthetic",
"CLERK_SECRET_KEY": "synthetic-secret",
"CLERK_AUTHORIZED_PARTIES": "http://localhost:4200",
"STRIPE_SECRET_KEY": "sk_test_synthetic",
"STRIPE_WEBHOOK_SECRET": "whsec_synthetic",
"JUNTLY_PUBLIC_ORIGIN": "https://vila.example",
"JUNTLY_PLATFORM_FEE_BPS": "1000",
}[key]
})
if err != nil {
t.Fatalf("load runtime config: %v", err)
}
if config.paymentGateway == nil || config.platformFeeBPS != 1000 {
t.Fatalf("payment config = %#v/%d", config.paymentGateway, config.platformFeeBPS)
}
}

func TestLoadRuntimeConfigRejectsPartialStripeConfiguration(t *testing.T) {
t.Parallel()
_, err := loadRuntimeConfig(func(key string) string {
return map[string]string{
"DATABASE_URL": "postgresql://synthetic",
"CLERK_SECRET_KEY": "synthetic-secret",
"CLERK_AUTHORIZED_PARTIES": "http://localhost:4200",
"STRIPE_SECRET_KEY": "sk_test_synthetic",
}[key]
})
if err == nil {
t.Fatal("partial Stripe configuration accepted")
}
}
4 changes: 3 additions & 1 deletion backend/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/listings"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/messaging"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/moderation"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/provideraccess"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/providers"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/quotations"
Expand Down Expand Up @@ -134,10 +135,11 @@ func newAPIHandler(config runtimeConfig) (http.Handler, io.Closer, error) {
listingRepository := listings.NewEntRepository(client)
listingDrafts := listings.NewService(providerAuthorizer, listingRepository)
moderatorAuthorizer := moderation.NewService(userService, moderation.NewEntRepository(client))
paymentService := payments.NewService(userService, moderatorAuthorizer, payments.NewSQLStore(database), config.paymentGateway, config.platformFeeBPS)
listingLifecycle := listings.NewLifecycleService(providerAuthorizer, moderatorAuthorizer, listingRepository)
listingMedia := listingmedia.NewService(providerAuthorizer, listingmedia.NewEntRepository(client), listingmedia.NewUnavailableStorage())
ownerListings := listings.NewOwnerService(listingDrafts, listingLifecycle, listingMedia)
moderationQueue := moderation.NewQueueService(moderatorAuthorizer, listingRepository)
moderationReview := moderation.NewReviewService(moderationQueue, listingLifecycle)
return httpapi.NewRouter(healthService, readinessService, config.verifier, userService, accountService, referenceService, providerService, ownerListings, moderationReview, publicDiscovery, contactChannels, contactReveal, messagingService, quotationService, bookingService, reviewService, entitlementService, administrationService), client, nil
return httpapi.NewRouter(healthService, readinessService, config.verifier, userService, accountService, referenceService, providerService, ownerListings, moderationReview, publicDiscovery, contactChannels, contactReveal, messagingService, quotationService, bookingService, reviewService, entitlementService, administrationService, paymentService), client, nil
}
24 changes: 21 additions & 3 deletions backend/internal/httpapi/health_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"net/http"
"strings"

"github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/health"
Expand Down Expand Up @@ -35,7 +36,7 @@ func (h HealthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(h.service.Check(requestID))
}

func NewRouter(service health.Service, readinessService ReadinessService, verifier authn.Verifier, reconcileService ReconcileService, accountService AccountService, referenceService ReferenceService, providerProfileService ProviderProfileService, listingService ListingService, moderationListingService ModerationListingService, publicDiscoveryService PublicDiscoveryService, contactChannelService ContactChannelService, contactRevealService ContactRevealService, messagingService MessagingService, quotationService QuotationService, bookingService BookingService, reviewService ReviewService, entitlementService EntitlementService, administrationService AdministrationService) http.Handler {
func NewRouter(service health.Service, readinessService ReadinessService, verifier authn.Verifier, reconcileService ReconcileService, accountService AccountService, referenceService ReferenceService, providerProfileService ProviderProfileService, listingService ListingService, moderationListingService ModerationListingService, publicDiscoveryService PublicDiscoveryService, contactChannelService ContactChannelService, contactRevealService ContactRevealService, messagingService MessagingService, quotationService QuotationService, bookingService BookingService, reviewService ReviewService, entitlementService EntitlementService, administrationService AdministrationService, paymentService PaymentService) http.Handler {
mux := http.NewServeMux()
mux.Handle("/api/v1/health", NewHealthHandler(service))
mux.Handle("/api/v1/ready", NewReadinessHandler(readinessService))
Expand All @@ -60,8 +61,10 @@ func NewRouter(service health.Service, readinessService ReadinessService, verifi
mux.Handle("/api/v1/me/quotation-requests", authn.RequireVerifiedIdentity(verifier, NewQuotationHandler(quotationService)))
mux.Handle("/api/v1/me/quotation-requests/", authn.RequireVerifiedIdentity(verifier, NewQuotationHandler(quotationService)))
mux.Handle("/api/v1/me/quotation-opportunities", authn.RequireVerifiedIdentity(verifier, NewQuotationHandler(quotationService)))
mux.Handle("/api/v1/me/bookings", authn.RequireVerifiedIdentity(verifier, NewBookingHandler(bookingService)))
mux.Handle("/api/v1/me/bookings/", authn.RequireVerifiedIdentity(verifier, NewBookingHandler(bookingService)))
bookingHandler := NewBookingHandler(bookingService)
paymentHandler := NewPaymentHandler(paymentService)
mux.Handle("/api/v1/me/bookings", authn.RequireVerifiedIdentity(verifier, bookingHandler))
mux.Handle("/api/v1/me/bookings/", authn.RequireVerifiedIdentity(verifier, dispatchCheckout(paymentHandler, bookingHandler)))
mux.Handle("/api/v1/me/reviews", authn.RequireVerifiedIdentity(verifier, NewReviewHandler(reviewService)))
mux.Handle("/api/v1/me/reviews/", authn.RequireVerifiedIdentity(verifier, NewReviewHandler(reviewService)))
mux.Handle("/api/v1/public/providers/", NewReviewHandler(reviewService))
Expand All @@ -71,9 +74,24 @@ func NewRouter(service health.Service, readinessService ReadinessService, verifi
mux.Handle("/api/v1/me/promotions", authn.RequireVerifiedIdentity(verifier, NewEntitlementHandler(entitlementService)))
mux.Handle("/api/v1/admin/dashboard", authn.RequireVerifiedIdentity(verifier, NewAdministrationHandler(administrationService)))
mux.Handle("/api/v1/admin/moderation", authn.RequireVerifiedIdentity(verifier, NewAdministrationHandler(administrationService)))
mux.Handle("/api/v1/me/payments", authn.RequireVerifiedIdentity(verifier, paymentHandler))
mux.Handle("/api/v1/me/payout-account", authn.RequireVerifiedIdentity(verifier, paymentHandler))
mux.Handle("/api/v1/admin/payments/", authn.RequireVerifiedIdentity(verifier, paymentHandler))
mux.Handle("/api/v1/admin/payments", authn.RequireVerifiedIdentity(verifier, paymentHandler))
mux.Handle("/api/v1/payments/webhooks/stripe", NewStripeWebhookHandler(paymentService))
return mux
}

func dispatchCheckout(payment, booking http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(strings.TrimSuffix(r.URL.Path, "/"), "/checkout") {
payment.ServeHTTP(w, r)
return
}
booking.ServeHTTP(w, r)
})
}

func requestIDFromHeader(value string) string {
if validRequestID(value) {
return value
Expand Down
192 changes: 192 additions & 0 deletions backend/internal/httpapi/payment_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package httpapi

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"

"github.com/SourceSenseiTheRealOne/juntly/backend/internal/authn"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/payments"
"github.com/SourceSenseiTheRealOne/juntly/backend/internal/users"
"github.com/google/uuid"
)

const maxPaymentRequestBytes = 256 * 1024

type PaymentService interface {
BeginCheckout(context.Context, users.VerifiedIdentity, uuid.UUID, string, string) (payments.CheckoutResult, error)
ListOrders(context.Context, users.VerifiedIdentity) ([]payments.Order, error)
ListAdminOrders(context.Context, users.VerifiedIdentity) ([]payments.Order, error)
BeginPayoutOnboarding(context.Context, users.VerifiedIdentity, string) (payments.PayoutOnboardingResult, error)
PayoutStatus(context.Context, users.VerifiedIdentity) (payments.ProviderAccount, error)
HandleWebhook(context.Context, []byte, string) error
Refund(context.Context, users.VerifiedIdentity, uuid.UUID, string) (payments.Order, error)
}

type paymentHandler struct{ service PaymentService }

type stripeWebhookHandler struct{ service PaymentService }

func NewPaymentHandler(service PaymentService) http.Handler { return paymentHandler{service: service} }
func NewStripeWebhookHandler(service PaymentService) http.Handler {
return stripeWebhookHandler{service: service}
}

func (h paymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestID := requestIDFromHeader(r.Header.Get(RequestIDHeader))
identity, ok := authn.IdentityFromContext(r.Context())
if !ok {
writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", requestID)
return
}
if h.service == nil {
writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", requestID)
return
}
path := strings.TrimSuffix(r.URL.Path, "/")
switch {
case path == "/api/v1/me/payments" && r.Method == http.MethodGet:
orders, err := h.service.ListOrders(r.Context(), identity)
if err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 200, map[string]any{"orders": orders}, requestID)
case path == "/api/v1/admin/payments" && r.Method == http.MethodGet:
orders, err := h.service.ListAdminOrders(r.Context(), identity)
if err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 200, map[string]any{"orders": orders}, requestID)
case path == "/api/v1/me/payout-account" && r.Method == http.MethodGet:
account, err := h.service.PayoutStatus(r.Context(), identity)
if err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 200, account, requestID)
case path == "/api/v1/me/payout-account" && r.Method == http.MethodPost:
var body struct {
Locale *string `json:"locale"`
}
if !decodePayment(r.Body, &body) || body.Locale == nil {
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
return
}
result, err := h.service.BeginPayoutOnboarding(r.Context(), identity, *body.Locale)
if err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 200, result, requestID)
case strings.HasPrefix(path, "/api/v1/me/bookings/") && strings.HasSuffix(path, "/checkout") && r.Method == http.MethodPost:
id, ok := paymentPathID(path, "/api/v1/me/bookings/", "/checkout")
if !ok {
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
return
}
var body struct {
IdempotencyKey *string `json:"idempotencyKey"`
Locale *string `json:"locale"`
}
if !decodePayment(r.Body, &body) || body.IdempotencyKey == nil || body.Locale == nil {
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
return
}
result, err := h.service.BeginCheckout(r.Context(), identity, id, *body.IdempotencyKey, *body.Locale)
if err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 201, result, requestID)
case strings.HasPrefix(path, "/api/v1/admin/payments/") && strings.HasSuffix(path, "/refund") && r.Method == http.MethodPost:
id, ok := paymentPathID(path, "/api/v1/admin/payments/", "/refund")
if !ok {
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
return
}
var body struct {
IdempotencyKey *string `json:"idempotencyKey"`
}
if !decodePayment(r.Body, &body) || body.IdempotencyKey == nil {
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
return
}
order, err := h.service.Refund(r.Context(), identity, id, *body.IdempotencyKey)
if err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 200, order, requestID)
default:
writeAPIError(w, 404, "NOT_FOUND", "Not found", requestID)
}
}

func (h stripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
requestID := requestIDFromHeader(r.Header.Get(RequestIDHeader))
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, http.StatusText(405), 405)
return
}
if h.service == nil {
writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", requestID)
return
}
signature := strings.TrimSpace(r.Header.Get("Stripe-Signature"))
if signature == "" {
writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", requestID)
return
}
payload, err := io.ReadAll(io.LimitReader(r.Body, maxPaymentRequestBytes+1))
if err != nil || len(payload) == 0 || len(payload) > maxPaymentRequestBytes {
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
return
}
if err := h.service.HandleWebhook(r.Context(), payload, signature); err != nil {
writePaymentError(w, err, requestID)
return
}
writeJSON(w, 200, map[string]bool{"received": true}, requestID)
}

func paymentPathID(path, prefix, suffix string) (uuid.UUID, bool) {
raw := strings.TrimSuffix(strings.TrimPrefix(path, prefix), suffix)
if strings.Contains(raw, "/") {
return uuid.Nil, false
}
id, err := uuid.Parse(raw)
return id, err == nil
}

func decodePayment(body io.Reader, target any) bool {
decoder := json.NewDecoder(io.LimitReader(body, 8*1024+1))
decoder.DisallowUnknownFields()
if decoder.Decode(target) != nil {
return false
}
var extra any
return errors.Is(decoder.Decode(&extra), io.EOF)
}

func writePaymentError(w http.ResponseWriter, err error, requestID string) {
switch {
case errors.Is(err, payments.ErrInvalid):
writeAPIError(w, 400, "INVALID_REQUEST", "Invalid request", requestID)
case errors.Is(err, payments.ErrUnauthorized):
writeAPIError(w, 401, "UNAUTHORIZED", "Unauthorized", requestID)
case errors.Is(err, payments.ErrForbidden):
writeAPIError(w, 403, "FORBIDDEN", "Forbidden", requestID)
case errors.Is(err, payments.ErrNotFound):
writeAPIError(w, 404, "NOT_FOUND", "Not found", requestID)
case errors.Is(err, payments.ErrConflict):
writeAPIError(w, 409, "CONFLICT", "Conflict", requestID)
default:
writeAPIError(w, 503, "SERVICE_UNAVAILABLE", "Service unavailable", requestID)
}
}
Loading