200 lines
6.4 KiB
Go
200 lines
6.4 KiB
Go
package billing
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/stripe/stripe-go/v82"
|
|
"github.com/stripe/stripe-go/v82/client"
|
|
"github.com/stripe/stripe-go/v82/webhook"
|
|
)
|
|
|
|
type StripeProvider struct{ api *client.API }
|
|
|
|
func NewStripeProvider(secretKey string) *StripeProvider {
|
|
return &StripeProvider{api: client.New(secretKey, nil)}
|
|
}
|
|
|
|
func (p *StripeProvider) CreateCheckout(ctx context.Context, input CheckoutParams) (*ProviderCheckout, error) {
|
|
uidText := stripe.String(formatUID(input.UID))
|
|
params := &stripe.CheckoutSessionParams{
|
|
Params: stripe.Params{Context: ctx},
|
|
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
|
SuccessURL: stripe.String(input.SuccessURL), CancelURL: stripe.String(input.CancelURL),
|
|
ClientReferenceID: uidText,
|
|
Metadata: map[string]string{"uid": formatUID(input.UID), "attempt_id": input.AttemptID, "plan_id": input.PlanID},
|
|
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{Metadata: map[string]string{"uid": formatUID(input.UID), "attempt_id": input.AttemptID}},
|
|
LineItems: []*stripe.CheckoutSessionLineItemParams{{Price: stripe.String(input.PriceID), Quantity: stripe.Int64(1)}},
|
|
}
|
|
if input.CustomerID != "" {
|
|
params.Customer = stripe.String(input.CustomerID)
|
|
}
|
|
params.SetIdempotencyKey(input.IdempotencyKey)
|
|
session, err := p.api.CheckoutSessions.New(params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return checkoutFromStripe(session), nil
|
|
}
|
|
|
|
func (p *StripeProvider) GetCheckout(ctx context.Context, id string) (*ProviderCheckout, error) {
|
|
params := &stripe.CheckoutSessionParams{Params: stripe.Params{Context: ctx}}
|
|
params.AddExpand("line_items.data.price")
|
|
session, err := p.api.CheckoutSessions.Get(id, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return checkoutFromStripe(session), nil
|
|
}
|
|
|
|
func checkoutFromStripe(s *stripe.CheckoutSession) *ProviderCheckout {
|
|
co := &ProviderCheckout{SessionID: s.ID, URL: s.URL, ExpiresAt: s.ExpiresAt * int64(time.Second), Status: string(s.Status), PaymentStatus: string(s.PaymentStatus)}
|
|
if s.Customer != nil {
|
|
co.CustomerID = s.Customer.ID
|
|
}
|
|
if s.Subscription != nil {
|
|
co.SubscriptionID = s.Subscription.ID
|
|
}
|
|
if s.LineItems != nil {
|
|
for _, item := range s.LineItems.Data {
|
|
if item != nil && item.Price != nil {
|
|
co.PriceID = item.Price.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return co
|
|
}
|
|
|
|
func (p *StripeProvider) GetSubscription(ctx context.Context, id string) (*ProviderSubscription, error) {
|
|
params := &stripe.SubscriptionParams{Params: stripe.Params{Context: ctx}}
|
|
params.AddExpand("items.data.price")
|
|
s, err := p.api.Subscriptions.Get(id, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := &ProviderSubscription{ID: s.ID, Status: string(s.Status), CancelAtPeriodEnd: s.CancelAtPeriodEnd}
|
|
if s.Customer != nil {
|
|
out.CustomerID = s.Customer.ID
|
|
}
|
|
if s.Items != nil {
|
|
for _, item := range s.Items.Data {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
if item.Price != nil && out.PriceID == "" {
|
|
out.PriceID = item.Price.ID
|
|
}
|
|
if item.CurrentPeriodStart > out.CurrentPeriodStart {
|
|
out.CurrentPeriodStart = item.CurrentPeriodStart
|
|
}
|
|
if item.CurrentPeriodEnd > out.CurrentPeriodEnd {
|
|
out.CurrentPeriodEnd = item.CurrentPeriodEnd
|
|
}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (p *StripeProvider) CreatePortal(ctx context.Context, customerID, returnURL string) (string, error) {
|
|
s, err := p.api.BillingPortalSessions.New(&stripe.BillingPortalSessionParams{Params: stripe.Params{Context: ctx}, Customer: stripe.String(customerID), ReturnURL: stripe.String(returnURL)})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return s.URL, nil
|
|
}
|
|
|
|
func (p *StripeProvider) VerifyEvent(payload []byte, signature, secret string) (*Event, error) {
|
|
e, err := webhook.ConstructEventWithOptions(payload, signature, secret, webhook.ConstructEventOptions{Tolerance: 5 * time.Minute})
|
|
if err != nil {
|
|
return nil, errors.Join(ErrInvalidSignature, err)
|
|
}
|
|
out := &Event{ID: e.ID, Type: string(e.Type), Created: e.Created}
|
|
var obj stripeObject
|
|
if err := json.Unmarshal(e.Data.Raw, &obj); err != nil {
|
|
return nil, err
|
|
}
|
|
out.ObjectID = obj.ID
|
|
out.CustomerID = objectID(obj.Customer)
|
|
out.Status = obj.Status
|
|
out.PeriodStart, out.PeriodEnd = obj.PeriodStart, obj.PeriodEnd
|
|
out.CancelAtPeriodEnd = obj.CancelAtPeriodEnd
|
|
out.SubscriptionID = objectID(obj.Subscription)
|
|
if out.SubscriptionID == "" {
|
|
out.SubscriptionID = objectID(obj.Parent.SubscriptionDetails.Subscription)
|
|
}
|
|
if len(obj.Items.Data) > 0 {
|
|
out.PriceID = objectID(obj.Items.Data[0].Price)
|
|
out.PeriodStart, out.PeriodEnd = obj.Items.Data[0].CurrentPeriodStart, obj.Items.Data[0].CurrentPeriodEnd
|
|
}
|
|
if len(obj.Lines.Data) > 0 {
|
|
line := obj.Lines.Data[0]
|
|
out.PriceID = objectID(line.Price)
|
|
if out.PriceID == "" {
|
|
out.PriceID = objectID(line.Pricing.PriceDetails.Price)
|
|
}
|
|
if line.Period.Start != 0 {
|
|
out.PeriodStart, out.PeriodEnd = line.Period.Start, line.Period.End
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type stripeObject struct {
|
|
ID string `json:"id"`
|
|
Customer json.RawMessage `json:"customer"`
|
|
Subscription json.RawMessage `json:"subscription"`
|
|
Status string `json:"status"`
|
|
PeriodStart int64 `json:"period_start"`
|
|
PeriodEnd int64 `json:"period_end"`
|
|
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
|
|
Parent struct {
|
|
SubscriptionDetails struct {
|
|
Subscription json.RawMessage `json:"subscription"`
|
|
} `json:"subscription_details"`
|
|
} `json:"parent"`
|
|
Items struct {
|
|
Data []struct {
|
|
Price json.RawMessage `json:"price"`
|
|
CurrentPeriodStart int64 `json:"current_period_start"`
|
|
CurrentPeriodEnd int64 `json:"current_period_end"`
|
|
} `json:"data"`
|
|
} `json:"items"`
|
|
Lines struct {
|
|
Data []struct {
|
|
Price json.RawMessage `json:"price"`
|
|
Period struct {
|
|
Start int64 `json:"start"`
|
|
End int64 `json:"end"`
|
|
} `json:"period"`
|
|
Pricing struct {
|
|
PriceDetails struct {
|
|
Price json.RawMessage `json:"price"`
|
|
} `json:"price_details"`
|
|
} `json:"pricing"`
|
|
} `json:"data"`
|
|
} `json:"lines"`
|
|
}
|
|
|
|
func objectID(raw json.RawMessage) string {
|
|
if len(raw) == 0 || string(raw) == "null" {
|
|
return ""
|
|
}
|
|
var id string
|
|
if json.Unmarshal(raw, &id) == nil {
|
|
return id
|
|
}
|
|
var obj struct {
|
|
ID string `json:"id"`
|
|
}
|
|
_ = json.Unmarshal(raw, &obj)
|
|
return obj.ID
|
|
}
|
|
|
|
func formatUID(uid int64) string { return strconv.FormatInt(uid, 10) }
|
|
|
|
var _ Provider = (*StripeProvider)(nil)
|