mirror of
https://github.com/hanzoai/commerce.git
synced 2026-10-30 15:08:42 +00:00
Square keeps an event's created_at across every retry, and the receiver refused anything older than five minutes, so every retry of an event first sent while cloud was down (Recreate, ten rollouts a day) was refused until Square gave up. The verifier now lives in payment/providers/square and needs only the key and the URL; thirdparty/square is the API client and no longer registers itself or verifies anything, so every registry holds one Square type. Replay is stopped by the event id instead: each verified event is recorded once for the whole deployment, keyed on the verifying processor and the event id with an insert-if-absent swap, so a repeat is a duplicate under any path or org. The path names the one processor that verifies, and only one that holds its webhook secret may: an unknown name, or a verifier that needs no secret, is refused rather than tried. The books an event lands in are the verifying account's, else the deployment's brand — never a header or session, which on a replay are the replayer's. An effect that did not land (a store or the ledger did not answer) leaves the delivery open with a 503, so the provider redelivers; it used to be acknowledged and never retried. A settlement reads the payment index before the org's books, because an in-session charge's receipt is in the payer's org. POST /v1/commerce/webhooks/:provider is declared once, in setupRoutes, so standalone and embedded commerce serve the same address; the /v1/billing/webhooks spelling is gone. thirdparty/kms read /v1/kms/orgs/<org>/secrets/..., which cloud no longer serves, and was off in production anyway, so per-org payment credentials never loaded. It is deleted. secrets.Org reads orgs/<org>/<name> through the host's reader, the caller re-pointed at that org with zip.ActingAs so the KMS tenancy check admits it, and payment.Hydrate loads an org's credentials where the org receives the money: storefront checkout, storefront subscriptions and wire, and the brand's bank for the billing wire rail. Hanzo's own billing no longer reads the paying org's credentials; it charges the deployment's account, as it did. The standalone binary reads through hanzoai/kms/sdk/go.
696 lines
24 KiB
Go
696 lines
24 KiB
Go
package checkout
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
nethttp "net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
square "github.com/square/square-go-sdk/v3"
|
|
sqcheckout "github.com/square/square-go-sdk/v3/checkout"
|
|
sqpaymentlinks "github.com/square/square-go-sdk/v3/checkout/paymentlinks"
|
|
"github.com/square/square-go-sdk/v3/core"
|
|
"github.com/square/square-go-sdk/v3/option"
|
|
"github.com/zap-proto/zip"
|
|
|
|
"github.com/hanzoai/commerce/billing/engine"
|
|
hostedcheckout "github.com/hanzoai/commerce/checkout"
|
|
"github.com/hanzoai/commerce/datastore"
|
|
"github.com/hanzoai/commerce/events"
|
|
"github.com/hanzoai/commerce/log"
|
|
"github.com/hanzoai/commerce/models/coupon"
|
|
"github.com/hanzoai/commerce/models/organization"
|
|
"github.com/hanzoai/commerce/models/store"
|
|
"github.com/hanzoai/commerce/models/types/currency"
|
|
"github.com/hanzoai/commerce/payment/processor"
|
|
sqenv "github.com/hanzoai/commerce/thirdparty/square"
|
|
"github.com/hanzoai/commerce/util/json/http"
|
|
)
|
|
|
|
// checkoutLineItem is a provider-agnostic line item used by both Stripe and Square checkout flows.
|
|
type checkoutLineItem struct {
|
|
Name string
|
|
Quantity int
|
|
Amount int64 // cents
|
|
}
|
|
|
|
type checkoutSessionCustomer struct {
|
|
FullName string `json:"fullName"`
|
|
Email string `json:"email"`
|
|
Address string `json:"address"`
|
|
City string `json:"city"`
|
|
Zip string `json:"zip"`
|
|
}
|
|
|
|
type checkoutSessionHat struct {
|
|
HatColor string `json:"hatColor"`
|
|
TextColor string `json:"textColor"`
|
|
Text string `json:"text"`
|
|
BackText string `json:"backText"`
|
|
Font string `json:"font"`
|
|
TextStyle string `json:"textStyle"`
|
|
FlagCode string `json:"flagCode"`
|
|
Size string `json:"size"`
|
|
}
|
|
|
|
type checkoutSessionItem struct {
|
|
ID string `json:"id"`
|
|
Quantity int `json:"quantity"`
|
|
UnitPrice float64 `json:"unitPrice"` // ignored; server computes price
|
|
// Catalog references. When any is set, the server prices the item from the
|
|
// org's real per-org store listing (never from client input) — this is the
|
|
// generic per-org storefront path. The legacy custom-hat path (Hat below) is
|
|
// used only when NO catalog reference resolves to a listing.
|
|
ProductId string `json:"productId,omitempty"`
|
|
ProductSlug string `json:"productSlug,omitempty"`
|
|
VariantSku string `json:"variantSku,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Hat checkoutSessionHat `json:"hat"`
|
|
}
|
|
|
|
type checkoutSessionRequest struct {
|
|
Company string `json:"company"`
|
|
ProviderHint string `json:"providerHint"`
|
|
Currency string `json:"currency"`
|
|
// NOTE: there is deliberately NO org/tenant field. The org is SOLELY the
|
|
// authenticated principal's (middleware.GetOrganization) — a client can only
|
|
// ever mint checkout for its OWN tenant, never one it names in the body.
|
|
Project string `json:"project"`
|
|
Customer checkoutSessionCustomer `json:"customer"`
|
|
Items []checkoutSessionItem `json:"items"`
|
|
SuccessURL string `json:"successUrl"`
|
|
CancelURL string `json:"cancelUrl"`
|
|
CouponCode string `json:"couponCode,omitempty"`
|
|
ReferrerId string `json:"referrerId,omitempty"`
|
|
AffiliateId string `json:"affiliateId,omitempty"`
|
|
}
|
|
|
|
// couponDiscount holds the resolved discount for a checkout session.
|
|
type couponDiscount struct {
|
|
Code string `json:"code"`
|
|
Type string `json:"type"`
|
|
Amount int `json:"amount"`
|
|
DiscountCents int64 `json:"discountCents"`
|
|
}
|
|
|
|
type checkoutSessionResponse struct {
|
|
CheckoutURL string `json:"checkoutUrl"`
|
|
SessionID string `json:"sessionId"`
|
|
Discount *couponDiscount `json:"discount,omitempty"`
|
|
OriginalTotal int64 `json:"originalTotal,omitempty"`
|
|
FinalTotal int64 `json:"finalTotal,omitempty"`
|
|
}
|
|
|
|
// resolveCoupon looks up a coupon by code in the datastore and returns it
|
|
// if valid. Returns nil (no error) when couponCode is empty.
|
|
func resolveCoupon(c *zip.Ctx, org *organization.Organization, couponCode string) (*coupon.Coupon, error) {
|
|
if couponCode == "" {
|
|
return nil, nil
|
|
}
|
|
// Per-org store (Red MED-1): coupons are per-org merchant entities, created in
|
|
// the org's own store. datastore.New(c) binds the shared systemDB (and drops
|
|
// the namespace), so with the resolver installed it would never find the
|
|
// caller's coupon → every hosted-checkout coupon silently "not found".
|
|
db := datastore.NewNamespaced(org.Namespaced(c.Context()))
|
|
cpn := coupon.New(db)
|
|
ok, err := cpn.Query().Filter("Code_=", strings.ToUpper(strings.TrimSpace(couponCode))).Get()
|
|
if err != nil || !ok {
|
|
return nil, errors.New("coupon not found")
|
|
}
|
|
if !cpn.ValidFor(time.Now()) || !cpn.Redeemable() {
|
|
return nil, errors.New("coupon expired or fully redeemed")
|
|
}
|
|
return cpn, nil
|
|
}
|
|
|
|
// applyDiscount computes the discount amount in cents given a subtotal and coupon.
|
|
// Returns the discount in cents (always >= 0).
|
|
func applyDiscount(subtotalCents int64, cpn *coupon.Coupon) int64 {
|
|
if cpn == nil {
|
|
return 0
|
|
}
|
|
switch cpn.Type {
|
|
case coupon.Percent:
|
|
// Amount field holds the percentage (e.g. 10 = 10%). Rounded, not floored by
|
|
// integer division: this is the same coupon the order model applies, and the
|
|
// number quoted at checkout has to be the number that ends up on the order.
|
|
discount := int64(currency.Cents(subtotalCents).Percent(cpn.Amount))
|
|
if discount > subtotalCents {
|
|
discount = subtotalCents
|
|
}
|
|
return discount
|
|
case coupon.Flat:
|
|
// Amount field holds cents (e.g. 500 = $5.00)
|
|
discount := int64(cpn.Amount)
|
|
if discount > subtotalCents {
|
|
discount = subtotalCents
|
|
}
|
|
return discount
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func normalizeColor(color string) string {
|
|
return strings.ToLower(strings.TrimSpace(color))
|
|
}
|
|
|
|
func isWhite(color string) bool {
|
|
c := normalizeColor(color)
|
|
return c == "#fff" || c == "#ffffff" || c == "white" || c == "rgb(255,255,255)"
|
|
}
|
|
|
|
func isBlack(color string) bool {
|
|
c := normalizeColor(color)
|
|
return c == "#000" || c == "#000000" || c == "black" || c == "rgb(0,0,0)"
|
|
}
|
|
|
|
func hatPriceCents(h checkoutSessionHat) int64 {
|
|
// Match storefront pricing logic: $80 only for true white-on-white or black-on-black.
|
|
whiteOnWhite := isWhite(h.HatColor) && isWhite(h.TextColor)
|
|
blackOnBlack := isBlack(h.HatColor) && isBlack(h.TextColor)
|
|
if whiteOnWhite || blackOnBlack {
|
|
return 8000
|
|
}
|
|
return 5000
|
|
}
|
|
|
|
func safeName(s string) string {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
|
s = strings.Join(strings.Fields(s), " ")
|
|
if s == "" {
|
|
return "MEGA Hat"
|
|
}
|
|
// Square line item name max is limited; keep it tight.
|
|
if len(s) > 60 {
|
|
return s[:60]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// hasCatalogRef reports whether a checkout item references a catalog listing
|
|
// (as opposed to a pure custom-hat item priced by configuration).
|
|
func hasCatalogRef(it checkoutSessionItem) bool {
|
|
return strings.TrimSpace(it.ProductId) != "" ||
|
|
strings.TrimSpace(it.ProductSlug) != "" ||
|
|
strings.TrimSpace(it.VariantSku) != ""
|
|
}
|
|
|
|
// itemRef returns a human-readable reference for error messages.
|
|
func itemRef(it checkoutSessionItem) string {
|
|
for _, s := range []string{it.ProductId, it.ProductSlug, it.VariantSku, it.ID} {
|
|
if strings.TrimSpace(s) != "" {
|
|
return s
|
|
}
|
|
}
|
|
return "(unspecified)"
|
|
}
|
|
|
|
// loadOrgCatalog loads the org's default (first) store listings from its own
|
|
// per-org datastore (Red MED-1: NewNamespaced, not the shared systemDB). Returns
|
|
// nil when the org has no store — callers then treat every item as a legacy hat.
|
|
func loadOrgCatalog(c *zip.Ctx, org *organization.Organization) store.Listings {
|
|
db := datastore.NewNamespaced(org.Namespaced(c.Context()))
|
|
s := store.New(db)
|
|
var stores []store.Store
|
|
if _, err := s.Query().All().Limit(1).GetAll(&stores); err != nil || len(stores) == 0 {
|
|
return nil
|
|
}
|
|
return stores[0].Listings
|
|
}
|
|
|
|
// catalogPrice resolves a checkout item against the org's real catalog and
|
|
// returns its listing name and price in cents. The price is authoritative —
|
|
// server-side, from the stored listing — never the client's UnitPrice. Returns
|
|
// ok=false when no visible, priced listing matches (caller rejects the item).
|
|
func catalogPrice(listings store.Listings, it checkoutSessionItem) (string, int64, bool) {
|
|
if listings == nil {
|
|
return "", 0, false
|
|
}
|
|
pid := strings.TrimSpace(it.ProductId)
|
|
slug := strings.TrimSpace(it.ProductSlug)
|
|
sku := strings.TrimSpace(it.VariantSku)
|
|
id := strings.TrimSpace(it.ID)
|
|
for key, l := range listings {
|
|
if l.Hidden != nil && *l.Hidden {
|
|
continue
|
|
}
|
|
match := (pid != "" && (l.ProductId == pid || l.VariantId == pid)) ||
|
|
(slug != "" && l.Slug == slug) ||
|
|
(sku != "" && l.SKU == sku) ||
|
|
(id != "" && (key == id || l.ProductId == id || l.Slug == id || l.SKU == id))
|
|
if !match {
|
|
continue
|
|
}
|
|
if l.Price == nil {
|
|
// Matched listing with no price cannot be sold.
|
|
return "", 0, false
|
|
}
|
|
name := key
|
|
if l.Name != nil && strings.TrimSpace(*l.Name) != "" {
|
|
name = strings.TrimSpace(*l.Name)
|
|
}
|
|
if len(name) > 60 {
|
|
name = name[:60]
|
|
}
|
|
return name, int64(*l.Price), true
|
|
}
|
|
return "", 0, false
|
|
}
|
|
|
|
func isValidRedirect(raw string) bool {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return false
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if u.Scheme != "https" && u.Scheme != "http" {
|
|
return false
|
|
}
|
|
if u.Host == "" {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// websiteURLs returns the org's registered website URLs — its OWN domains — for
|
|
// the checkout redirect allowlist.
|
|
func websiteURLs(org *organization.Organization) []string {
|
|
if org == nil || len(org.Websites) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, len(org.Websites))
|
|
for _, w := range org.Websites {
|
|
if u := strings.TrimSpace(w.Url); u != "" {
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// squareCheckoutClientForOrg creates a Square Payment Links client using the
|
|
// org's own credentials (payment.Hydrate). A sandbox org with none uses the
|
|
// deployment's sandbox account; a live org with none is refused.
|
|
func squareCheckoutClientForOrg(org *organization.Organization) (*sqpaymentlinks.Client, string, error) {
|
|
isSandbox := org.TestMode()
|
|
sqCfg := org.SquareConfig(isSandbox)
|
|
|
|
token := sqCfg.AccessToken
|
|
locationID := sqCfg.LocationId
|
|
|
|
// FAIL-CLOSED in production. An org that has not configured its OWN
|
|
// per-tenant Square credentials (KMS) can NEVER borrow the deployment's
|
|
// env-configured account to mint a REAL square.link. There is no env
|
|
// fallback and no platform-org special case for live money — the whole
|
|
// "anonymous env-token / platform-org" lending path is gone. Only SANDBOX
|
|
// (no money at risk) keeps the env fallback, so the per-org storefront
|
|
// sandbox demo still mints intents behind authentication, on the sandbox
|
|
// names only (sqenv.FromEnv).
|
|
if token == "" {
|
|
if !isSandbox {
|
|
return nil, "", errors.New("square is not configured for this organization")
|
|
}
|
|
creds := sqenv.FromEnv(true)
|
|
token, locationID = creds.AccessToken, creds.LocationID
|
|
}
|
|
|
|
if token == "" || locationID == "" {
|
|
return nil, "", errors.New("square is not configured for this organization")
|
|
}
|
|
|
|
baseURL := "https://connect.squareup.com"
|
|
if isSandbox {
|
|
baseURL = "https://connect.squareupsandbox.com"
|
|
}
|
|
|
|
client := sqpaymentlinks.NewClient(core.NewRequestOptions(
|
|
option.WithToken(token),
|
|
option.WithBaseURL(baseURL),
|
|
))
|
|
return client, locationID, nil
|
|
}
|
|
|
|
// Sessions creates a provider-agnostic hosted checkout session.
|
|
//
|
|
// Currently implemented using Square Payment Links (hosted checkout URL).
|
|
// When providerHint is "wire", returns wire transfer instructions instead.
|
|
func Sessions(c *zip.Ctx) error {
|
|
var req checkoutSessionRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return http.Fail(c, 400, "Invalid request", err)
|
|
}
|
|
|
|
if len(req.Items) == 0 {
|
|
return http.Fail(c, 400, "No items", errors.New("items is required"))
|
|
}
|
|
|
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
|
if currency == "" {
|
|
currency = "USD"
|
|
}
|
|
if currency != "USD" {
|
|
return http.Fail(c, 400, "Unsupported currency", errors.New("only USD is supported"))
|
|
}
|
|
|
|
// Wire transfer: return instructions URL instead of creating a payment link.
|
|
if strings.ToLower(strings.TrimSpace(req.ProviderHint)) == "wire" {
|
|
sessionID := uuid.New().String()
|
|
baseURL := strings.TrimSpace(os.Getenv("BASE_URL"))
|
|
if baseURL == "" {
|
|
baseURL = "https://api.hanzo.ai"
|
|
}
|
|
wireURL := baseURL + "/v1/checkout/wire/instructions"
|
|
|
|
return http.Render(c, 200, checkoutSessionResponse{
|
|
CheckoutURL: wireURL,
|
|
SessionID: sessionID,
|
|
})
|
|
}
|
|
|
|
// Org is SOLELY the authenticated principal's — the route's
|
|
// TokenRequired(Admin, Published) branch (service token / Published
|
|
// storefront token) or the IAM identity middleware set it. There is NO
|
|
// request-body/header/env org override on the mint path: one org source, so
|
|
// a caller can only ever mint checkout for its OWN tenant. Fail closed.
|
|
org, ok := authedOrg(c)
|
|
if !ok {
|
|
return http.Fail(c, 401, "Authentication required",
|
|
errors.New("no authenticated organization for checkout"))
|
|
}
|
|
|
|
// The minted payment link's post-payment redirect MUST target one of the
|
|
// org's OWN domains — otherwise a link carrying the org's brand and a real
|
|
// charge becomes an open-redirect / phishing pivot.
|
|
sites := websiteURLs(org)
|
|
if !hostedcheckout.AllowedCheckoutRedirect(req.SuccessURL, org.Name, sites, c.Fiber().Host()) {
|
|
return http.Fail(c, 400, "Invalid successUrl",
|
|
errors.New("successUrl must be one of the organization's own domains"))
|
|
}
|
|
if strings.TrimSpace(req.CancelURL) != "" &&
|
|
!hostedcheckout.AllowedCheckoutRedirect(req.CancelURL, org.Name, sites, c.Fiber().Host()) {
|
|
return http.Fail(c, 400, "Invalid cancelUrl",
|
|
errors.New("cancelUrl must be one of the organization's own domains"))
|
|
}
|
|
|
|
// Resolve coupon before any external calls so we can fail fast.
|
|
cpn, err := resolveCoupon(c, org, req.CouponCode)
|
|
if err != nil {
|
|
return http.Fail(c, 400, "Invalid coupon", err)
|
|
}
|
|
|
|
// Load the org's real per-org catalog once. Items that reference a catalog
|
|
// listing (productId/slug/sku) are priced from the listing SERVER-SIDE — the
|
|
// client-supplied UnitPrice is never trusted. Items with no catalog reference
|
|
// fall back to the legacy custom-hat pricing. An item that DOES reference the
|
|
// catalog but matches no listing is rejected (400) — never silently mispriced.
|
|
catalog := loadOrgCatalog(c, org)
|
|
|
|
// Compute subtotal and line items (provider-agnostic).
|
|
items := make([]checkoutLineItem, 0, len(req.Items))
|
|
var subtotalCents int64
|
|
for _, it := range req.Items {
|
|
if it.Quantity <= 0 {
|
|
return http.Fail(c, 400, "Invalid quantity", fmt.Errorf("quantity must be > 0 for item '%s'", it.ID))
|
|
}
|
|
var name string
|
|
var amount int64
|
|
if hasCatalogRef(it) {
|
|
n, a, ok := catalogPrice(catalog, it)
|
|
if !ok {
|
|
return http.Fail(c, 400, "Unknown product", fmt.Errorf("no priced listing for item '%s'", itemRef(it)))
|
|
}
|
|
name, amount = n, a
|
|
} else {
|
|
name = safeName(it.Hat.Text)
|
|
amount = hatPriceCents(it.Hat)
|
|
}
|
|
subtotalCents += amount * int64(it.Quantity)
|
|
items = append(items, checkoutLineItem{Name: name, Quantity: it.Quantity, Amount: amount})
|
|
}
|
|
|
|
// Apply coupon discount.
|
|
discountCents := applyDiscount(subtotalCents, cpn)
|
|
finalCents := subtotalCents - discountCents
|
|
|
|
// Select payment provider, honoring the SAME disabled-processor policy
|
|
// SelectProcessor enforces (processor.DisabledByPolicy). Stripe is disabled
|
|
// by default — "we do NOT use Stripe" — so neither an explicit stripe hint
|
|
// nor a hydrated Stripe token routes a NEW checkout to Stripe; Square is
|
|
// used. Stripe checkout code stays for historical / existing-customer flows.
|
|
hint := strings.ToLower(strings.TrimSpace(req.ProviderHint))
|
|
stripeAllowed := !processor.DisabledByPolicy(processor.Stripe)
|
|
var sessionResp checkoutSessionResponse
|
|
|
|
switch {
|
|
case stripeAllowed && (hint == "stripe" || (hint == "" && org.StripeToken() != "")):
|
|
sessionResp, err = createStripeCheckout(c, org, items, subtotalCents, discountCents, finalCents, cpn, currency, req)
|
|
|
|
default:
|
|
// Square Payment Links (default).
|
|
sessionResp, err = createSquareCheckout(c, org, items, subtotalCents, discountCents, finalCents, cpn, currency, req)
|
|
}
|
|
|
|
if err != nil {
|
|
return http.Fail(c, 500, "Failed to create checkout session", err)
|
|
}
|
|
|
|
if cpn != nil {
|
|
sessionResp.OriginalTotal = subtotalCents
|
|
sessionResp.FinalTotal = finalCents
|
|
sessionResp.Discount = &couponDiscount{
|
|
Code: cpn.Code(),
|
|
Type: string(cpn.Type),
|
|
Amount: cpn.Amount,
|
|
DiscountCents: discountCents,
|
|
}
|
|
}
|
|
// Publish checkout.started to NATS/JetStream (fire and forget)
|
|
if pub := c.Locals("publisher"); pub != nil {
|
|
if p, ok := pub.(*events.Publisher); ok {
|
|
// Detach from the request (survive client disconnect) but keep trace
|
|
// values — WithoutCancel, not Background. Captured before the goroutine
|
|
// because fiber recycles the request context after the handler returns.
|
|
ctx := context.WithoutCancel(c.Context())
|
|
go func() {
|
|
if pubErr := p.PublishCheckoutStarted(ctx, sessionResp.SessionID, org.Name, finalCents, currency); pubErr != nil {
|
|
log.Error("PublishCheckoutStarted: %v", pubErr, c)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Deliver checkout.started to subscribed billing webhook endpoints (fire and
|
|
// forget, off the money path — same lifecycle point as the bus publisher).
|
|
// Detach the request context (zip recycles c after the handler) before it
|
|
// enters the goroutine, then namespace it to the org.
|
|
engine.Emit(org.Namespaced(context.WithoutCancel(c.Context())), "checkout.started", "checkout_session", sessionResp.SessionID, "", map[string]interface{}{
|
|
"sessionId": sessionResp.SessionID,
|
|
"value": float64(finalCents) / 100.0,
|
|
"currency": currency,
|
|
})
|
|
|
|
return http.Render(c, 200, sessionResp)
|
|
}
|
|
|
|
// createStripeCheckout creates a Stripe Checkout Session using the org's Stripe credentials.
|
|
func createStripeCheckout(c *zip.Ctx, org *organization.Organization, items []checkoutLineItem, subtotalCents, discountCents, finalCents int64, cpn *coupon.Coupon, currency string, req checkoutSessionRequest) (checkoutSessionResponse, error) {
|
|
sk := org.StripeToken()
|
|
if sk == "" {
|
|
return checkoutSessionResponse{}, errors.New("stripe is not configured for this organization")
|
|
}
|
|
|
|
// Build Stripe Checkout Session line_items
|
|
params := url.Values{}
|
|
params.Set("mode", "payment")
|
|
params.Set("success_url", req.SuccessURL)
|
|
if isValidRedirect(req.CancelURL) {
|
|
params.Set("cancel_url", req.CancelURL)
|
|
}
|
|
if req.Customer.Email != "" {
|
|
params.Set("customer_email", strings.TrimSpace(req.Customer.Email))
|
|
}
|
|
|
|
for i, it := range items {
|
|
prefix := fmt.Sprintf("line_items[%d]", i)
|
|
params.Set(prefix+"[price_data][currency]", strings.ToLower(currency))
|
|
params.Set(prefix+"[price_data][unit_amount]", fmt.Sprintf("%d", it.Amount))
|
|
params.Set(prefix+"[price_data][product_data][name]", it.Name)
|
|
params.Set(prefix+"[quantity]", fmt.Sprintf("%d", it.Quantity))
|
|
}
|
|
|
|
// Apply coupon as a discount if present
|
|
if discountCents > 0 && cpn != nil {
|
|
// Create an inline coupon via discounts parameter
|
|
params.Set("discounts[0][coupon]", "")
|
|
// Use a promotion code or just adjust — Stripe Checkout doesn't support inline flat discounts
|
|
// directly, so we add a negative line item or use automatic_tax. For simplicity, adjust via
|
|
// a coupon created on-the-fly is complex. Instead, we adjust the unit amounts proportionally
|
|
// or note the discount in metadata.
|
|
params.Set("metadata[coupon_code]", cpn.Code())
|
|
params.Set("metadata[discount_cents]", fmt.Sprintf("%d", discountCents))
|
|
params.Del("discounts[0][coupon]")
|
|
}
|
|
|
|
params.Set("metadata[org]", org.Name)
|
|
if req.ReferrerId != "" {
|
|
params.Set("metadata[referrer_id]", req.ReferrerId)
|
|
}
|
|
if req.AffiliateId != "" {
|
|
params.Set("metadata[affiliate_id]", req.AffiliateId)
|
|
}
|
|
|
|
stripeReq, err := nethttp.NewRequestWithContext(c.Context(), nethttp.MethodPost,
|
|
"https://api.stripe.com/v1/checkout/sessions",
|
|
strings.NewReader(params.Encode()))
|
|
if err != nil {
|
|
return checkoutSessionResponse{}, fmt.Errorf("stripe request: %w", err)
|
|
}
|
|
stripeReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
stripeReq.SetBasicAuth(sk, "")
|
|
|
|
resp, err := nethttp.DefaultClient.Do(stripeReq)
|
|
if err != nil {
|
|
return checkoutSessionResponse{}, fmt.Errorf("stripe API: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return checkoutSessionResponse{}, fmt.Errorf("stripe read: %w", err)
|
|
}
|
|
|
|
if resp.StatusCode >= 400 {
|
|
return checkoutSessionResponse{}, fmt.Errorf("stripe error %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var stripeResp struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.Unmarshal(body, &stripeResp); err != nil {
|
|
return checkoutSessionResponse{}, fmt.Errorf("stripe parse: %w", err)
|
|
}
|
|
|
|
return checkoutSessionResponse{
|
|
CheckoutURL: stripeResp.URL,
|
|
SessionID: stripeResp.ID,
|
|
}, nil
|
|
}
|
|
|
|
// createSquareCheckout creates a Square Payment Link using the org's Square credentials.
|
|
func createSquareCheckout(c *zip.Ctx, org *organization.Organization, items []checkoutLineItem, subtotalCents, discountCents, finalCents int64, cpn *coupon.Coupon, currency string, req checkoutSessionRequest) (checkoutSessionResponse, error) {
|
|
client, locationID, err := squareCheckoutClientForOrg(org)
|
|
if err != nil {
|
|
return checkoutSessionResponse{}, err
|
|
}
|
|
|
|
lineItems := make([]*square.OrderLineItem, 0, len(items))
|
|
for _, it := range items {
|
|
name := it.Name
|
|
qty := fmt.Sprintf("%d", it.Quantity)
|
|
amount := it.Amount
|
|
cur := square.CurrencyUsd
|
|
|
|
lineItems = append(lineItems, &square.OrderLineItem{
|
|
Name: &name,
|
|
Quantity: qty,
|
|
BasePriceMoney: &square.Money{
|
|
Amount: &amount,
|
|
Currency: &cur,
|
|
},
|
|
})
|
|
}
|
|
|
|
referenceID := uuid.New().String()
|
|
order := &square.Order{
|
|
LocationID: locationID,
|
|
LineItems: lineItems,
|
|
ReferenceID: &referenceID,
|
|
}
|
|
|
|
if discountCents > 0 && cpn != nil {
|
|
discountUID := "coupon-discount"
|
|
discountName := strings.ToUpper(cpn.Code())
|
|
discountType := square.OrderLineItemDiscountTypeFixedAmount
|
|
discountScope := square.OrderLineItemDiscountScopeOrder
|
|
cur := square.CurrencyUsd
|
|
order.Discounts = []*square.OrderLineItemDiscount{
|
|
{
|
|
UID: &discountUID,
|
|
Name: &discountName,
|
|
Type: &discountType,
|
|
Scope: &discountScope,
|
|
AmountMoney: &square.Money{
|
|
Amount: &discountCents,
|
|
Currency: &cur,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
redirectURL := req.SuccessURL
|
|
askShipping := false
|
|
checkoutOptions := &square.CheckoutOptions{
|
|
RedirectURL: &redirectURL,
|
|
AskForShippingAddress: &askShipping,
|
|
}
|
|
|
|
buyerEmail := strings.TrimSpace(req.Customer.Email)
|
|
buyerName := strings.TrimSpace(req.Customer.FullName)
|
|
buyerAddressLine1 := strings.TrimSpace(req.Customer.Address)
|
|
buyerCity := strings.TrimSpace(req.Customer.City)
|
|
buyerZip := strings.TrimSpace(req.Customer.Zip)
|
|
|
|
var prePop *square.PrePopulatedData
|
|
if buyerEmail != "" || buyerAddressLine1 != "" {
|
|
prePop = &square.PrePopulatedData{
|
|
BuyerEmail: &buyerEmail,
|
|
BuyerAddress: &square.Address{
|
|
FirstName: &buyerName,
|
|
AddressLine1: &buyerAddressLine1,
|
|
Locality: &buyerCity,
|
|
PostalCode: &buyerZip,
|
|
},
|
|
}
|
|
}
|
|
|
|
desc := strings.TrimSpace(req.Company)
|
|
if desc == "" {
|
|
desc = "Checkout"
|
|
}
|
|
idempotency := uuid.New().String()
|
|
|
|
createReq := &sqcheckout.CreatePaymentLinkRequest{
|
|
IdempotencyKey: &idempotency,
|
|
Description: &desc,
|
|
Order: order,
|
|
CheckoutOptions: checkoutOptions,
|
|
PrePopulatedData: prePop,
|
|
}
|
|
|
|
sqResp, err := client.Create(c.Context(), createReq)
|
|
if err != nil {
|
|
return checkoutSessionResponse{}, fmt.Errorf("square API: %w", err)
|
|
}
|
|
if len(sqResp.Errors) > 0 {
|
|
return checkoutSessionResponse{}, fmt.Errorf("square: %s", sqResp.Errors[0].String())
|
|
}
|
|
if sqResp.PaymentLink == nil || sqResp.PaymentLink.ID == nil || sqResp.PaymentLink.URL == nil {
|
|
return checkoutSessionResponse{}, errors.New("square: missing payment link in response")
|
|
}
|
|
|
|
return checkoutSessionResponse{
|
|
CheckoutURL: *sqResp.PaymentLink.URL,
|
|
SessionID: *sqResp.PaymentLink.ID,
|
|
}, nil
|
|
}
|