diff --git a/billing/checkout/checkout.go b/billing/checkout/checkout.go index b2cfdab30d..bc71d040c9 100644 --- a/billing/checkout/checkout.go +++ b/billing/checkout/checkout.go @@ -20,11 +20,12 @@ func (s State) String() string { } var ( - ErrNotFound = errors.New("checkout not found") - ErrInvalidUUID = errors.New("invalid syntax of uuid") - ErrInvalidID = errors.New("invalid checkout id") - ErrInvalidDetail = errors.New("invalid checkout detail") - ErrKycCompleted = errors.New("organization kyc completed") + ErrNotFound = errors.New("checkout not found") + ErrInvalidUUID = errors.New("invalid syntax of uuid") + ErrInvalidID = errors.New("invalid checkout id") + ErrInvalidDetail = errors.New("invalid checkout detail") + ErrKycCompleted = errors.New("organization kyc completed") + ErrAlreadySubscribed = errors.New("already subscribed to the plan") ) type Checkout struct { diff --git a/billing/checkout/service.go b/billing/checkout/service.go index 3ab5ca5b1d..01a61e2d9b 100644 --- a/billing/checkout/service.go +++ b/billing/checkout/service.go @@ -248,7 +248,7 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) { if subID, err := s.checkIfAlreadySubscribed(ctx, ch); err != nil { return Checkout{}, err } else if subID != "" { - return Checkout{}, fmt.Errorf("already subscribed to the plan") + return Checkout{}, ErrAlreadySubscribed } // create subscription items @@ -894,7 +894,7 @@ func (s *Service) Apply(ctx context.Context, ch Checkout) (*subscription.Subscri if subID, err := s.checkIfAlreadySubscribed(ctx, ch); err != nil { return nil, nil, err } else if subID != "" { - return nil, nil, fmt.Errorf("already subscribed to the plan") + return nil, nil, ErrAlreadySubscribed } if err := s.cancelTrialingSubscription(ctx, ch.CustomerID, ch.PlanID); err != nil { diff --git a/internal/api/v1beta1connect/billing_check.go b/internal/api/v1beta1connect/billing_check.go index 20e63ce7c6..298b5b8dc3 100644 --- a/internal/api/v1beta1connect/billing_check.go +++ b/internal/api/v1beta1connect/billing_check.go @@ -20,12 +20,12 @@ func (h *ConnectHandler) CheckFeatureEntitlement(ctx context.Context, request *c if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CheckFeatureEntitlement.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CheckFeatureEntitlement.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } checkStatus, err := h.entitlementService.Check(ctx, cust.ID, request.Msg.GetFeature()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CheckFeatureEntitlement: billing_id=%s org_id=%s feature=%s: %w", cust.ID, request.Msg.GetOrgId(), request.Msg.GetFeature(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CheckFeatureEntitlement: billing_id=%s org_id=%s feature=%s: %w", cust.ID, request.Msg.GetOrgId(), request.Msg.GetFeature(), err)) } return connect.NewResponse(&frontierv1beta1.CheckFeatureEntitlementResponse{ @@ -38,7 +38,7 @@ func (h *ConnectHandler) CheckCreditEntitlement(ctx context.Context, request *co OrgID: request.Msg.GetOrgId(), }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CheckCreditEntitlement.List: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CheckCreditEntitlement.List: org_id=%s: %w", request.Msg.GetOrgId(), err)) } if len(customerList) == 0 { @@ -48,12 +48,12 @@ func (h *ConnectHandler) CheckCreditEntitlement(ctx context.Context, request *co customer := customerList[0] customerDetails, err := h.customerService.GetDetails(ctx, customer.ID) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CheckCreditEntitlement.GetDetails: customer_id=%s org_id=%s: %w", customer.ID, request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CheckCreditEntitlement.GetDetails: customer_id=%s org_id=%s: %w", customer.ID, request.Msg.GetOrgId(), err)) } creditBalance, err := h.creditService.GetBalance(ctx, customer.ID) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CheckCreditEntitlement.GetBalance: customer_id=%s org_id=%s: %w", customer.ID, request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CheckCreditEntitlement.GetBalance: customer_id=%s org_id=%s: %w", customer.ID, request.Msg.GetOrgId(), err)) } if creditBalance-request.Msg.GetAmount() >= customerDetails.CreditMin { diff --git a/internal/api/v1beta1connect/billing_checkout.go b/internal/api/v1beta1connect/billing_checkout.go index 954a0d33d7..e20f81981b 100644 --- a/internal/api/v1beta1connect/billing_checkout.go +++ b/internal/api/v1beta1connect/billing_checkout.go @@ -20,7 +20,7 @@ func (h *ConnectHandler) CreateCheckout(ctx context.Context, request *connect.Re // Always infer billing_id from org_id (ignore billing_id from request for security) billingID, err := h.GetBillingAccountFromOrgID(ctx, request.Msg.GetOrgId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateCheckout.GetBillingAccountFromOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateCheckout.GetBillingAccountFromOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } // check if setup requested @@ -31,7 +31,7 @@ func (h *ConnectHandler) CreateCheckout(ctx context.Context, request *connect.Re CancelUrl: request.Msg.GetCancelUrl(), }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateCheckout.CreateSessionForPaymentMethod: billing_id=%s: %w", billingID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateCheckout.CreateSessionForPaymentMethod: billing_id=%s: %w", billingID, err)) } return connect.NewResponse(&frontierv1beta1.CreateCheckoutResponse{ @@ -50,7 +50,7 @@ func (h *ConnectHandler) CreateCheckout(ctx context.Context, request *connect.Re if errors.Is(err, checkout.ErrKycCompleted) { return nil, connect.NewError(connect.CodeFailedPrecondition, ErrPortalChangesKycCompleted) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateCheckout.CreateSessionForCustomerPortal: billing_id=%s: %w", billingID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateCheckout.CreateSessionForCustomerPortal: billing_id=%s: %w", billingID, err)) } // Audit the customer portal session creation so we can trace who (a super @@ -120,7 +120,7 @@ func (h *ConnectHandler) CreateCheckout(ctx context.Context, request *connect.Re if errors.Is(err, product.ErrPerSeatLimitReached) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrPerSeatLimitReached) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateCheckout.Create: billing_id=%s plan_id=%s product_id=%s quantity=%d skip_trial=%v cancel_after_trial=%v: %w", billingID, planID, featureID, quantity, skipTrial, cancelAfterTrial, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateCheckout.Create: billing_id=%s plan_id=%s product_id=%s quantity=%d skip_trial=%v cancel_after_trial=%v: %w", billingID, planID, featureID, quantity, skipTrial, cancelAfterTrial, err)) } return connect.NewResponse(&frontierv1beta1.CreateCheckoutResponse{ @@ -132,7 +132,7 @@ func (h *ConnectHandler) DelegatedCheckout(ctx context.Context, request *connect // Always infer billing_id from org_id (ignore billing_id from request for security) billingID, err := h.GetBillingAccountFromOrgID(ctx, request.Msg.GetOrgId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DelegatedCheckout.GetBillingAccountFromOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("DelegatedCheckout.GetBillingAccountFromOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } var planID string @@ -161,19 +161,19 @@ func (h *ConnectHandler) DelegatedCheckout(ctx context.Context, request *connect ProviderCouponID: providerCouponID, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DelegatedCheckout.Apply: billing_id=%s plan_id=%s product_id=%s product_quantity=%d skip_trial=%v cancel_after_trial=%v provider_coupon_id=%s: %w", billingID, planID, productID, productQuantity, skipTrial, cancelAfterTrail, providerCouponID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("DelegatedCheckout.Apply: billing_id=%s plan_id=%s product_id=%s product_quantity=%d skip_trial=%v cancel_after_trial=%v provider_coupon_id=%s: %w", billingID, planID, productID, productQuantity, skipTrial, cancelAfterTrail, providerCouponID, err)) } var subsPb *frontierv1beta1.Subscription if subs != nil { if subsPb, err = transformSubscriptionToPB(*subs); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DelegatedCheckout: subscription_id=%s: %w", subs.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("DelegatedCheckout: subscription_id=%s: %w", subs.ID, err)) } } var productPb *frontierv1beta1.Product if prod != nil { if productPb, err = transformProductToPB(*prod); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DelegatedCheckout: product_id=%s: %w", prod.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("DelegatedCheckout: product_id=%s: %w", prod.ID, err)) } } @@ -191,7 +191,7 @@ func (h *ConnectHandler) ListCheckouts(ctx context.Context, request *connect.Req // Always infer billing_id from org_id (ignore billing_id from request for security) billingID, err := h.GetBillingAccountFromOrgID(ctx, request.Msg.GetOrgId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListCheckouts.GetBillingAccountFromOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListCheckouts.GetBillingAccountFromOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } var checkouts []*frontierv1beta1.CheckoutSession @@ -199,7 +199,7 @@ func (h *ConnectHandler) ListCheckouts(ctx context.Context, request *connect.Req CustomerID: billingID, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListCheckouts.List: billing_id=%s org_id=%s: %w", billingID, request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListCheckouts.List: billing_id=%s org_id=%s: %w", billingID, request.Msg.GetOrgId(), err)) } for _, v := range checkoutList { checkouts = append(checkouts, transformCheckoutToPB(v)) @@ -217,7 +217,7 @@ func (h *ConnectHandler) GetCheckout(ctx context.Context, request *connect.Reque ch, err := h.checkoutService.GetByID(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetCheckout.GetByID: checkout_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetCheckout.GetByID: checkout_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.GetCheckoutResponse{ diff --git a/internal/api/v1beta1connect/billing_customer.go b/internal/api/v1beta1connect/billing_customer.go index e43c93e734..40d4beab31 100644 --- a/internal/api/v1beta1connect/billing_customer.go +++ b/internal/api/v1beta1connect/billing_customer.go @@ -55,12 +55,12 @@ func (h *ConnectHandler) CreateBillingAccount(ctx context.Context, request *conn if errors.Is(err, customer.ErrActiveConflict) { return nil, connect.NewError(connect.CodeFailedPrecondition, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateBillingAccount.Create: org_id=%s customer_name=%s customer_email=%s currency=%s offline=%v: %w", request.Msg.GetOrgId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetEmail(), request.Msg.GetBody().GetCurrency(), request.Msg.GetOffline(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateBillingAccount.Create: org_id=%s customer_name=%s customer_email=%s currency=%s offline=%v: %w", request.Msg.GetOrgId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetEmail(), request.Msg.GetBody().GetCurrency(), request.Msg.GetOffline(), err)) } customerPB, err := transformCustomerToPB(newCustomer) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateBillingAccount: customer_id=%s: %w", newCustomer.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateBillingAccount: customer_id=%s: %w", newCustomer.ID, err)) } return connect.NewResponse(&frontierv1beta1.CreateBillingAccountResponse{ BillingAccount: customerPB, @@ -105,12 +105,12 @@ func (h *ConnectHandler) UpdateBillingAccount(ctx context.Context, request *conn TaxData: customerTaxes, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateBillingAccount.Update: customer_id=%s customer_name=%s customer_email=%s currency=%s: %w", request.Msg.GetId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetEmail(), request.Msg.GetBody().GetCurrency(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("UpdateBillingAccount.Update: customer_id=%s customer_name=%s customer_email=%s currency=%s: %w", request.Msg.GetId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetEmail(), request.Msg.GetBody().GetCurrency(), err)) } customerPB, err := transformCustomerToPB(updatedCustomer) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateBillingAccount: customer_id=%s: %w", updatedCustomer.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("UpdateBillingAccount: customer_id=%s: %w", updatedCustomer.ID, err)) } return connect.NewResponse(&frontierv1beta1.UpdateBillingAccountResponse{ @@ -124,7 +124,7 @@ func (h *ConnectHandler) RegisterBillingAccount(ctx context.Context, request *co if errors.Is(err, customer.ErrNotFound) { return nil, connect.NewError(connect.CodeNotFound, ErrCustomerNotFound) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RegisterBillingAccount.RegisterToProviderIfRequired: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("RegisterBillingAccount.RegisterToProviderIfRequired: customer_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.RegisterBillingAccountResponse{}), nil } @@ -138,12 +138,12 @@ func (h *ConnectHandler) ListBillingAccounts(ctx context.Context, request *conne OrgID: request.Msg.GetOrgId(), }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListBillingAccounts.List: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListBillingAccounts.List: org_id=%s: %w", request.Msg.GetOrgId(), err)) } for _, v := range customerList { customerPB, err := transformCustomerToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListBillingAccounts: customer_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListBillingAccounts: customer_id=%s: %w", v.ID, err)) } customers = append(customers, customerPB) } @@ -161,7 +161,7 @@ func (h *ConnectHandler) ListBillingAccounts(ctx context.Context, request *conne func (h *ConnectHandler) DeleteBillingAccount(ctx context.Context, request *connect.Request[frontierv1beta1.DeleteBillingAccountRequest]) (*connect.Response[frontierv1beta1.DeleteBillingAccountResponse], error) { err := h.customerService.Delete(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteBillingAccount.Delete: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("DeleteBillingAccount.Delete: customer_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.DeleteBillingAccountResponse{}), nil } @@ -169,7 +169,7 @@ func (h *ConnectHandler) DeleteBillingAccount(ctx context.Context, request *conn func (h *ConnectHandler) EnableBillingAccount(ctx context.Context, request *connect.Request[frontierv1beta1.EnableBillingAccountRequest]) (*connect.Response[frontierv1beta1.EnableBillingAccountResponse], error) { err := h.customerService.Enable(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("EnableBillingAccount.Enable: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("EnableBillingAccount.Enable: customer_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.EnableBillingAccountResponse{}), nil } @@ -177,7 +177,7 @@ func (h *ConnectHandler) EnableBillingAccount(ctx context.Context, request *conn func (h *ConnectHandler) DisableBillingAccount(ctx context.Context, request *connect.Request[frontierv1beta1.DisableBillingAccountRequest]) (*connect.Response[frontierv1beta1.DisableBillingAccountResponse], error) { err := h.customerService.Disable(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DisableBillingAccount.Disable: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("DisableBillingAccount.Disable: customer_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.DisableBillingAccountResponse{}), nil } @@ -185,7 +185,7 @@ func (h *ConnectHandler) DisableBillingAccount(ctx context.Context, request *con func (h *ConnectHandler) GetBillingBalance(ctx context.Context, request *connect.Request[frontierv1beta1.GetBillingBalanceRequest]) (*connect.Response[frontierv1beta1.GetBillingBalanceResponse], error) { balanceAmount, err := h.creditService.GetBalance(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingBalance.GetBalance: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingBalance.GetBalance: customer_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.GetBillingBalanceResponse{ Balance: &frontierv1beta1.BillingAccount_Balance{ @@ -198,7 +198,7 @@ func (h *ConnectHandler) GetBillingBalance(ctx context.Context, request *connect func (h *ConnectHandler) HasTrialed(ctx context.Context, request *connect.Request[frontierv1beta1.HasTrialedRequest]) (*connect.Response[frontierv1beta1.HasTrialedResponse], error) { hasTrialed, err := h.subscriptionService.HasUserSubscribedBefore(ctx, request.Msg.GetId(), request.Msg.GetPlanId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("HasTrialed.HasUserSubscribedBefore: customer_id=%s plan_id=%s: %w", request.Msg.GetId(), request.Msg.GetPlanId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("HasTrialed.HasUserSubscribedBefore: customer_id=%s plan_id=%s: %w", request.Msg.GetId(), request.Msg.GetPlanId(), err)) } return connect.NewResponse(&frontierv1beta1.HasTrialedResponse{ Trialed: hasTrialed, @@ -211,12 +211,12 @@ func (h *ConnectHandler) ListAllBillingAccounts(ctx context.Context, request *co OrgID: request.Msg.GetOrgId(), }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListAllBillingAccounts.List: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListAllBillingAccounts.List: org_id=%s: %w", request.Msg.GetOrgId(), err)) } for _, v := range customerList { customerPB, err := transformCustomerToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListAllBillingAccounts: customer_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListAllBillingAccounts: customer_id=%s: %w", v.ID, err)) } customers = append(customers, customerPB) } @@ -265,7 +265,7 @@ func transformCustomerToPB(customer customer.Customer) (*frontierv1beta1.Billing func (h *ConnectHandler) UpdateBillingAccountLimits(ctx context.Context, request *connect.Request[frontierv1beta1.UpdateBillingAccountLimitsRequest]) (*connect.Response[frontierv1beta1.UpdateBillingAccountLimitsResponse], error) { _, err := h.customerService.UpdateCreditMinByID(ctx, request.Msg.GetId(), request.Msg.GetCreditMin()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateBillingAccountLimits.UpdateCreditMinByID: customer_id=%s credit_min=%d: %w", request.Msg.GetId(), request.Msg.GetCreditMin(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("UpdateBillingAccountLimits.UpdateCreditMinByID: customer_id=%s credit_min=%d: %w", request.Msg.GetId(), request.Msg.GetCreditMin(), err)) } return connect.NewResponse(&frontierv1beta1.UpdateBillingAccountLimitsResponse{}), nil @@ -274,7 +274,7 @@ func (h *ConnectHandler) UpdateBillingAccountLimits(ctx context.Context, request func (h *ConnectHandler) GetBillingAccountDetails(ctx context.Context, request *connect.Request[frontierv1beta1.GetBillingAccountDetailsRequest]) (*connect.Response[frontierv1beta1.GetBillingAccountDetailsResponse], error) { details, err := h.customerService.GetDetails(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingAccountDetails.GetDetails: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingAccountDetails.GetDetails: customer_id=%s: %w", request.Msg.GetId(), err)) } return connect.NewResponse(&frontierv1beta1.GetBillingAccountDetailsResponse{ @@ -289,19 +289,19 @@ func (h *ConnectHandler) GetBillingAccount(ctx context.Context, request *connect if errors.Is(err, customer.ErrNotFound) { return nil, connect.NewError(connect.CodeNotFound, ErrNotFound) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingAccount.GetByID: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingAccount.GetByID: customer_id=%s: %w", request.Msg.GetId(), err)) } var paymentMethodsPbs []*frontierv1beta1.PaymentMethod if request.Msg.GetWithPaymentMethods() { pms, err := h.customerService.ListPaymentMethods(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingAccount.ListPaymentMethods: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingAccount.ListPaymentMethods: customer_id=%s: %w", request.Msg.GetId(), err)) } for _, v := range pms { pmPB, err := transformPaymentMethodToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingAccount: payment_method_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingAccount: payment_method_id=%s: %w", v.ID, err)) } paymentMethodsPbs = append(paymentMethodsPbs, pmPB) } @@ -311,7 +311,7 @@ func (h *ConnectHandler) GetBillingAccount(ctx context.Context, request *connect if request.Msg.GetWithBillingDetails() { billingDetails, err := h.customerService.GetDetails(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingAccount.GetDetails: customer_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingAccount.GetDetails: customer_id=%s: %w", request.Msg.GetId(), err)) } billingDetailsPb = &frontierv1beta1.BillingAccountDetails{ CreditMin: billingDetails.CreditMin, @@ -321,7 +321,7 @@ func (h *ConnectHandler) GetBillingAccount(ctx context.Context, request *connect customerPB, err := transformCustomerToPB(customerOb) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetBillingAccount: customer_id=%s: %w", customerOb.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetBillingAccount: customer_id=%s: %w", customerOb.ID, err)) } response := &frontierv1beta1.GetBillingAccountResponse{ @@ -367,7 +367,7 @@ func (h *ConnectHandler) UpdateBillingAccountDetails(ctx context.Context, reques DueInDays: request.Msg.GetDueInDays(), }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateBillingAccountDetails.UpdateDetails: customer_id=%s credit_min=%d due_in_days=%d: %w", request.Msg.GetId(), request.Msg.GetCreditMin(), request.Msg.GetDueInDays(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("UpdateBillingAccountDetails.UpdateDetails: customer_id=%s credit_min=%d due_in_days=%d: %w", request.Msg.GetId(), request.Msg.GetCreditMin(), request.Msg.GetDueInDays(), err)) } // Add audit log - infer org_id from billing account diff --git a/internal/api/v1beta1connect/billing_errors.go b/internal/api/v1beta1connect/billing_errors.go new file mode 100644 index 0000000000..3706dd4dbd --- /dev/null +++ b/internal/api/v1beta1connect/billing_errors.go @@ -0,0 +1,82 @@ +package v1beta1connect + +import ( + "context" + "errors" + "log/slog" + "regexp" + + "connectrpc.com/connect" + + "github.com/raystack/frontier/billing/checkout" + "github.com/raystack/frontier/billing/customer" + billingerrors "github.com/raystack/frontier/billing/errors" + "github.com/raystack/frontier/billing/product" + "github.com/raystack/frontier/billing/subscription" +) + +// provider-generated object ids: a short lowercase prefix, an optional +// test/live mode segment, and a long random alphanumeric part. The pattern is +// deliberately general so an id of an object type we haven't seen still gets +// masked; caller-supplied values like coupon codes don't match this shape. +var providerIDPattern = regexp.MustCompile(`\b([a-z]{2,10})_(?:(?:test|live)_)?[A-Za-z0-9]{8,}\b`) + +// redactedProviderError hides provider object ids in a message shown to the +// caller, keeping the rest of the provider's text. +func redactedProviderError(providerErr *billingerrors.ProviderError) error { + return errors.New(providerIDPattern.ReplaceAllString(providerErr.Error(), "${1}_*****")) +} + +// mapBillingError is the fallback for billing handlers in place of a bare +// CodeInternal. Provider and account-state problems reach the caller as +// codes they can act on; everything else stays internal. Mapped errors are +// logged here with their full detail, because the caller-facing error is +// deliberately stripped of it and that error is all the logger interceptor +// sees. +func mapBillingError(ctx context.Context, err error) *connect.Error { + mapped := mapBillingErrorCode(err) + if mapped.Code() != connect.CodeInternal { + args := []any{"error", err, "code", mapped.Code().String()} + var providerErr *billingerrors.ProviderError + if errors.As(err, &providerErr) && providerErr.RequestID != "" { + args = append(args, "provider_request_id", providerErr.RequestID) + } + slog.WarnContext(ctx, "billing request failed", args...) + } + return mapped +} + +func mapBillingErrorCode(err error) *connect.Error { + switch { + case errors.Is(err, billingerrors.ErrProviderResourceMissing): + var providerErr *billingerrors.ProviderError + if errors.As(err, &providerErr) { + return connect.NewError(connect.CodeFailedPrecondition, redactedProviderError(providerErr)) + } + return connect.NewError(connect.CodeFailedPrecondition, billingerrors.ErrProviderResourceMissing) + case errors.Is(err, billingerrors.ErrPaymentFailed): + var providerErr *billingerrors.ProviderError + if errors.As(err, &providerErr) { + return connect.NewError(connect.CodeFailedPrecondition, redactedProviderError(providerErr)) + } + return connect.NewError(connect.CodeFailedPrecondition, billingerrors.ErrPaymentFailed) + case errors.Is(err, billingerrors.ErrProviderUnavailable): + return connect.NewError(connect.CodeUnavailable, ErrBillingProviderUnavailable) + case errors.Is(err, subscription.ErrSubscriptionOnProviderNotFound): + return connect.NewError(connect.CodeFailedPrecondition, ErrSubscriptionProviderMissing) + case errors.Is(err, subscription.ErrPhaseIsUpdating): + return connect.NewError(connect.CodeFailedPrecondition, subscription.ErrPhaseIsUpdating) + case errors.Is(err, customer.ErrExistingAccountWithPendingDues): + return connect.NewError(connect.CodeFailedPrecondition, customer.ErrExistingAccountWithPendingDues) + case errors.Is(err, customer.ErrNotFound): + return connect.NewError(connect.CodeNotFound, ErrCustomerNotFound) + case errors.Is(err, checkout.ErrAlreadySubscribed): + return connect.NewError(connect.CodeAlreadyExists, checkout.ErrAlreadySubscribed) + case errors.Is(err, product.ErrProductNotFound): + return connect.NewError(connect.CodeNotFound, product.ErrProductNotFound) + case errors.Is(err, product.ErrFeatureNotFound): + return connect.NewError(connect.CodeNotFound, product.ErrFeatureNotFound) + default: + return connect.NewError(connect.CodeInternal, err) + } +} diff --git a/internal/api/v1beta1connect/billing_errors_test.go b/internal/api/v1beta1connect/billing_errors_test.go new file mode 100644 index 0000000000..4b1aa90fc0 --- /dev/null +++ b/internal/api/v1beta1connect/billing_errors_test.go @@ -0,0 +1,147 @@ +package v1beta1connect + +import ( + "context" + "errors" + "fmt" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + stripe "github.com/stripe/stripe-go/v79" + + "github.com/raystack/frontier/billing/checkout" + "github.com/raystack/frontier/billing/customer" + billingerrors "github.com/raystack/frontier/billing/errors" + "github.com/raystack/frontier/billing/product" + "github.com/raystack/frontier/billing/subscription" +) + +func TestMapBillingError(t *testing.T) { + deadCustomer := billingerrors.TranslateStripeError(&stripe.Error{ + Code: stripe.ErrorCodeResourceMissing, + Msg: "No such customer: 'cus_QhBNKtbzOZzumU'", + }) + deadCoupon := billingerrors.TranslateStripeError(&stripe.Error{ + Code: stripe.ErrorCodeResourceMissing, + Msg: "No such coupon: 'SUMMER20'", + }) + cardDeclined := billingerrors.TranslateStripeError(&stripe.Error{ + Type: stripe.ErrorTypeCard, + Msg: "Your card was declined.", + }) + rateLimited := billingerrors.TranslateStripeError(&stripe.Error{ + Code: stripe.ErrorCodeRateLimit, + }) + + tests := []struct { + name string + err error + wantCode connect.Code + wantMsg string + }{ + { + name: "provider resource missing masks the provider id", + err: fmt.Errorf("GetUpcomingInvoice: org_id=abc: %w", deadCustomer), + wantCode: connect.CodeFailedPrecondition, + wantMsg: "record no longer exists on the billing provider: No such customer: 'cus_*****'", + }, + { + name: "provider resource missing keeps a caller-supplied value", + err: fmt.Errorf("DelegatedCheckout: %w", deadCoupon), + wantCode: connect.CodeFailedPrecondition, + wantMsg: "record no longer exists on the billing provider: No such coupon: 'SUMMER20'", + }, + { + name: "provider resource missing masks a test-mode checkout session id", + err: fmt.Errorf("GetCheckout: %w", billingerrors.TranslateStripeError(&stripe.Error{ + Code: stripe.ErrorCodeResourceMissing, + Msg: "No such checkout.session: 'cs_test_c1GSMJhe9lzCEkJAVj3R5ife'", + })), + wantCode: connect.CodeFailedPrecondition, + wantMsg: "record no longer exists on the billing provider: No such checkout.session: 'cs_*****'", + }, + { + name: "provider resource missing without provider error", + err: fmt.Errorf("GetUpcomingInvoice: %w", billingerrors.ErrProviderResourceMissing), + wantCode: connect.CodeFailedPrecondition, + wantMsg: billingerrors.ErrProviderResourceMissing.Error(), + }, + { + name: "provider resource missing masks an unlisted id kind", + err: fmt.Errorf("GetTransaction: %w", billingerrors.TranslateStripeError(&stripe.Error{ + Code: stripe.ErrorCodeResourceMissing, + Msg: "No such balance transaction: 'txn_3PqR7sTuVwXyZ012'", + })), + wantCode: connect.CodeFailedPrecondition, + wantMsg: "record no longer exists on the billing provider: No such balance transaction: 'txn_*****'", + }, + { + name: "payment failed keeps provider message", + err: fmt.Errorf("CreateCheckout: %w", cardDeclined), + wantCode: connect.CodeFailedPrecondition, + wantMsg: "payment failed: Your card was declined.", + }, + { + name: "provider unavailable", + err: fmt.Errorf("ListInvoices: %w", rateLimited), + wantCode: connect.CodeUnavailable, + wantMsg: ErrBillingProviderUnavailable.Error(), + }, + { + name: "subscription gone on provider", + err: fmt.Errorf("ChangeSubscription: %w", subscription.ErrSubscriptionOnProviderNotFound), + wantCode: connect.CodeFailedPrecondition, + wantMsg: ErrSubscriptionProviderMissing.Error(), + }, + { + name: "plan change in progress", + err: fmt.Errorf("ChangeSubscription: %w", subscription.ErrPhaseIsUpdating), + wantCode: connect.CodeFailedPrecondition, + wantMsg: subscription.ErrPhaseIsUpdating.Error(), + }, + { + name: "pending dues", + err: fmt.Errorf("CreateBillingAccount: %w", customer.ErrExistingAccountWithPendingDues), + wantCode: connect.CodeFailedPrecondition, + wantMsg: customer.ErrExistingAccountWithPendingDues.Error(), + }, + { + name: "customer not found", + err: fmt.Errorf("CreateCheckout.GetBillingAccountFromOrgID: org_id=abc: %w", customer.ErrNotFound), + wantCode: connect.CodeNotFound, + wantMsg: ErrCustomerNotFound.Error(), + }, + { + name: "already subscribed to the plan", + err: fmt.Errorf("CreateCheckout.Create: %w", checkout.ErrAlreadySubscribed), + wantCode: connect.CodeAlreadyExists, + wantMsg: checkout.ErrAlreadySubscribed.Error(), + }, + { + name: "product not found", + err: fmt.Errorf("GetProduct.GetByID: product_id=abc: %w", product.ErrProductNotFound), + wantCode: connect.CodeNotFound, + wantMsg: product.ErrProductNotFound.Error(), + }, + { + name: "feature not found", + err: fmt.Errorf("CheckFeatureEntitlement: feature=abc: %w", product.ErrFeatureNotFound), + wantCode: connect.CodeNotFound, + wantMsg: product.ErrFeatureNotFound.Error(), + }, + { + name: "unknown error stays internal", + err: fmt.Errorf("GetBillingAccount: %w", errors.New("db down")), + wantCode: connect.CodeInternal, + wantMsg: "GetBillingAccount: db down", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mapBillingError(context.Background(), tt.err) + assert.Equal(t, tt.wantCode, got.Code()) + assert.Equal(t, tt.wantMsg, got.Message()) + }) + } +} diff --git a/internal/api/v1beta1connect/billing_invoice.go b/internal/api/v1beta1connect/billing_invoice.go index 16268d7df4..033066c3ba 100644 --- a/internal/api/v1beta1connect/billing_invoice.go +++ b/internal/api/v1beta1connect/billing_invoice.go @@ -22,13 +22,13 @@ func (h *ConnectHandler) ListAllInvoices(ctx context.Context, request *connect.R Pagination: paginate, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListAllInvoices.ListAll: page_num=%d page_size=%d: %w", request.Msg.GetPageNum(), request.Msg.GetPageSize(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListAllInvoices.ListAll: page_num=%d page_size=%d: %w", request.Msg.GetPageNum(), request.Msg.GetPageSize(), err)) } var invoicePBs []*frontierv1beta1.Invoice for _, v := range invoices { invoicePB, err := transformInvoiceToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListAllInvoices: invoice_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListAllInvoices: invoice_id=%s: %w", v.ID, err)) } invoicePBs = append(invoicePBs, invoicePB) } @@ -53,7 +53,7 @@ func (h *ConnectHandler) ListInvoices(ctx context.Context, request *connect.Requ if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListInvoices.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListInvoices.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } billingID := cust.ID @@ -62,13 +62,13 @@ func (h *ConnectHandler) ListInvoices(ctx context.Context, request *connect.Requ NonZeroOnly: request.Msg.GetNonzeroAmountOnly(), }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListInvoices.List: org_id=%s billing_id=%s nonzero_amount_only=%v: %w", request.Msg.GetOrgId(), billingID, request.Msg.GetNonzeroAmountOnly(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListInvoices.List: org_id=%s billing_id=%s nonzero_amount_only=%v: %w", request.Msg.GetOrgId(), billingID, request.Msg.GetNonzeroAmountOnly(), err)) } var invoicePBs []*frontierv1beta1.Invoice for _, v := range invoices { invoicePB, err := transformInvoiceToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListInvoices: invoice_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListInvoices: invoice_id=%s: %w", v.ID, err)) } invoicePBs = append(invoicePBs, invoicePB) } @@ -97,17 +97,17 @@ func (h *ConnectHandler) GetUpcomingInvoice(ctx context.Context, request *connec if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetUpcomingInvoice.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetUpcomingInvoice.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } billingID := cust.ID invoice, err := h.invoiceService.GetUpcoming(ctx, billingID) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetUpcomingInvoice.GetUpcoming: org_id=%s billing_id=%s: %w", request.Msg.GetOrgId(), billingID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetUpcomingInvoice.GetUpcoming: org_id=%s billing_id=%s: %w", request.Msg.GetOrgId(), billingID, err)) } invoicePB, err := transformInvoiceToPB(invoice) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetUpcomingInvoice: invoice_id=%s: %w", invoice.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetUpcomingInvoice: invoice_id=%s: %w", invoice.ID, err)) } return connect.NewResponse(&frontierv1beta1.GetUpcomingInvoiceResponse{ @@ -152,7 +152,7 @@ func transformInvoiceToPB(i invoice.Invoice) (*frontierv1beta1.Invoice, error) { func (h *ConnectHandler) GenerateInvoices(ctx context.Context, request *connect.Request[frontierv1beta1.GenerateInvoicesRequest]) (*connect.Response[frontierv1beta1.GenerateInvoicesResponse], error) { err := h.invoiceService.TriggerCreditOverdraftInvoices(ctx) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GenerateInvoices.TriggerCreditOverdraftInvoices: %w", err)) + return nil, mapBillingError(ctx, fmt.Errorf("GenerateInvoices.TriggerCreditOverdraftInvoices: %w", err)) } return connect.NewResponse(&frontierv1beta1.GenerateInvoicesResponse{}), nil } @@ -175,7 +175,7 @@ func (h *ConnectHandler) SearchInvoices(ctx context.Context, request *connect.Re if errors.Is(err, invoice.ErrBadInput) { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("SearchInvoices.SearchInvoices: query_offset=%d query_limit=%d: %w", rqlQuery.Offset, rqlQuery.Limit, err)) + return nil, mapBillingError(ctx, fmt.Errorf("SearchInvoices.SearchInvoices: query_offset=%d query_limit=%d: %w", rqlQuery.Offset, rqlQuery.Limit, err)) } for _, v := range invoicesData { diff --git a/internal/api/v1beta1connect/billing_plan.go b/internal/api/v1beta1connect/billing_plan.go index d2a5cf0e99..5375bcd2ec 100644 --- a/internal/api/v1beta1connect/billing_plan.go +++ b/internal/api/v1beta1connect/billing_plan.go @@ -79,17 +79,17 @@ func (h *ConnectHandler) CreatePlan(ctx context.Context, request *connect.Reques Products: products, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreatePlan.UpsertPlans: plan_name=%s plan_title=%s interval=%s product_count=%d: %w", planToCreate.Name, planToCreate.Title, planToCreate.Interval, len(products), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreatePlan.UpsertPlans: plan_name=%s plan_title=%s interval=%s product_count=%d: %w", planToCreate.Name, planToCreate.Title, planToCreate.Interval, len(products), err)) } newPlan, err := h.planService.GetByID(ctx, planToCreate.Name) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreatePlan.GetByID: plan_name=%s: %w", planToCreate.Name, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreatePlan.GetByID: plan_name=%s: %w", planToCreate.Name, err)) } planPB, err := transformPlanToPB(newPlan) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreatePlan: plan_id=%s: %w", newPlan.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreatePlan: plan_id=%s: %w", newPlan.ID, err)) } return connect.NewResponse(&frontierv1beta1.CreatePlanResponse{Plan: planPB}), nil @@ -99,12 +99,12 @@ func (h *ConnectHandler) ListPlans(ctx context.Context, request *connect.Request var plans []*frontierv1beta1.Plan planList, err := h.planService.List(ctx, plan.Filter{}) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListPlans.List: %w", err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListPlans.List: %w", err)) } for _, v := range planList { planPB, err := transformPlanToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListPlans: plan_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListPlans: plan_id=%s: %w", v.ID, err)) } plans = append(plans, planPB) } @@ -115,12 +115,12 @@ func (h *ConnectHandler) ListPlans(ctx context.Context, request *connect.Request func (h *ConnectHandler) GetPlan(ctx context.Context, request *connect.Request[frontierv1beta1.GetPlanRequest]) (*connect.Response[frontierv1beta1.GetPlanResponse], error) { planOb, err := h.planService.GetByID(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetPlan.GetByID: plan_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetPlan.GetByID: plan_id=%s: %w", request.Msg.GetId(), err)) } planPB, err := transformPlanToPB(planOb) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetPlan: plan_id=%s: %w", planOb.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetPlan: plan_id=%s: %w", planOb.ID, err)) } return connect.NewResponse(&frontierv1beta1.GetPlanResponse{Plan: planPB}), nil diff --git a/internal/api/v1beta1connect/billing_product.go b/internal/api/v1beta1connect/billing_product.go index cee1be9f95..8626aaa8e4 100644 --- a/internal/api/v1beta1connect/billing_product.go +++ b/internal/api/v1beta1connect/billing_product.go @@ -15,12 +15,12 @@ func (h *ConnectHandler) ListProducts(ctx context.Context, request *connect.Requ var products []*frontierv1beta1.Product productsList, err := h.productService.List(ctx, product.Filter{}) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListProducts.List: %w", err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListProducts.List: %w", err)) } for _, v := range productsList { productPB, err := transformProductToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListProducts: entity_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListProducts: entity_id=%s: %w", v.ID, err)) } products = append(products, productPB) } @@ -33,12 +33,12 @@ func (h *ConnectHandler) ListProducts(ctx context.Context, request *connect.Requ func (h *ConnectHandler) GetProduct(ctx context.Context, request *connect.Request[frontierv1beta1.GetProductRequest]) (*connect.Response[frontierv1beta1.GetProductResponse], error) { product, err := h.productService.GetByID(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetProduct.GetByID: product_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetProduct.GetByID: product_id=%s: %w", request.Msg.GetId(), err)) } productPB, err := transformProductToPB(product) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetProduct: entity_id=%s: %w", product.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetProduct: entity_id=%s: %w", product.ID, err)) } return connect.NewResponse(&frontierv1beta1.GetProductResponse{ @@ -93,14 +93,14 @@ func (h *ConnectHandler) CreateProduct(ctx context.Context, request *connect.Req Metadata: metaDataMap, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateProduct.Create: product_name=%s product_title=%s behavior=%s price_count=%d feature_count=%d: %w", + return nil, mapBillingError(ctx, fmt.Errorf("CreateProduct.Create: product_name=%s product_title=%s behavior=%s price_count=%d feature_count=%d: %w", request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(), request.Msg.GetBody().GetBehavior(), len(productPrices), len(productFeatures), err)) } productPB, err := transformProductToPB(newProduct) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateProduct: entity_id=%s: %w", newProduct.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateProduct: entity_id=%s: %w", newProduct.ID, err)) } return connect.NewResponse(&frontierv1beta1.CreateProductResponse{ @@ -161,19 +161,19 @@ func (h *ConnectHandler) UpdateProduct(ctx context.Context, request *connect.Req Metadata: metaDataMap, }) if err != nil { + wrapped := fmt.Errorf("UpdateProduct.Update: product_id=%s product_name=%s product_title=%s behavior=%s price_count=%d feature_count=%d: %w", + request.Msg.GetId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(), + request.Msg.GetBody().GetBehavior(), len(productPrices), len(productFeatures), err) // an invalid price (bad name, duplicate, or a change to an immutable // field) is the caller's fault, so report it as an invalid argument. - code := connect.CodeInternal if errors.Is(err, product.ErrInvalidDetail) { - code = connect.CodeInvalidArgument + return nil, connect.NewError(connect.CodeInvalidArgument, wrapped) } - return nil, connect.NewError(code, fmt.Errorf("UpdateProduct.Update: product_id=%s product_name=%s product_title=%s behavior=%s price_count=%d feature_count=%d: %w", - request.Msg.GetId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(), - request.Msg.GetBody().GetBehavior(), len(productPrices), len(productFeatures), err)) + return nil, mapBillingError(ctx, wrapped) } productPb, err := transformProductToPB(updatedProduct) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateProduct: entity_id=%s: %w", updatedProduct.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("UpdateProduct: entity_id=%s: %w", updatedProduct.ID, err)) } return connect.NewResponse(&frontierv1beta1.UpdateProductResponse{ @@ -184,14 +184,14 @@ func (h *ConnectHandler) UpdateProduct(ctx context.Context, request *connect.Req func (h *ConnectHandler) ListFeatures(ctx context.Context, request *connect.Request[frontierv1beta1.ListFeaturesRequest]) (*connect.Response[frontierv1beta1.ListFeaturesResponse], error) { features, err := h.productService.ListFeatures(ctx, product.Filter{}) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListFeatures.ListFeatures: %w", err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListFeatures.ListFeatures: %w", err)) } var featuresPB []*frontierv1beta1.Feature for _, v := range features { f, err := transformFeatureToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListFeatures: entity_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListFeatures: entity_id=%s: %w", v.ID, err)) } featuresPB = append(featuresPB, f) } @@ -213,13 +213,13 @@ func (h *ConnectHandler) CreateFeature(ctx context.Context, request *connect.Req if errors.Is(err, product.ErrInvalidFeatureDetail) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateFeature.UpsertFeature: feature_name=%s feature_title=%s product_ids=%v: %w", + return nil, mapBillingError(ctx, fmt.Errorf("CreateFeature.UpsertFeature: feature_name=%s feature_title=%s product_ids=%v: %w", request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(), request.Msg.GetBody().GetProductIds(), err)) } featurePB, err := transformFeatureToPB(newFeature) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateFeature: entity_id=%s: %w", newFeature.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateFeature: entity_id=%s: %w", newFeature.ID, err)) } return connect.NewResponse(&frontierv1beta1.CreateFeatureResponse{ @@ -240,13 +240,13 @@ func (h *ConnectHandler) UpdateFeature(ctx context.Context, request *connect.Req if errors.Is(err, product.ErrInvalidFeatureDetail) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateFeature.UpsertFeature: feature_id=%s feature_name=%s feature_title=%s product_ids=%v: %w", + return nil, mapBillingError(ctx, fmt.Errorf("UpdateFeature.UpsertFeature: feature_id=%s feature_name=%s feature_title=%s product_ids=%v: %w", request.Msg.GetId(), request.Msg.GetBody().GetName(), request.Msg.GetBody().GetTitle(), request.Msg.GetBody().GetProductIds(), err)) } featurePB, err := transformFeatureToPB(updatedFeature) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateFeature: entity_id=%s: %w", updatedFeature.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("UpdateFeature: entity_id=%s: %w", updatedFeature.ID, err)) } return connect.NewResponse(&frontierv1beta1.UpdateFeatureResponse{ @@ -257,12 +257,12 @@ func (h *ConnectHandler) UpdateFeature(ctx context.Context, request *connect.Req func (h *ConnectHandler) GetFeature(ctx context.Context, request *connect.Request[frontierv1beta1.GetFeatureRequest]) (*connect.Response[frontierv1beta1.GetFeatureResponse], error) { feature, err := h.productService.GetFeatureByID(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetFeature.GetFeatureByID: feature_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetFeature.GetFeatureByID: feature_id=%s: %w", request.Msg.GetId(), err)) } featurePB, err := transformFeatureToPB(feature) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetFeature: entity_id=%s: %w", feature.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetFeature: entity_id=%s: %w", feature.ID, err)) } return connect.NewResponse(&frontierv1beta1.GetFeatureResponse{ diff --git a/internal/api/v1beta1connect/billing_subscription.go b/internal/api/v1beta1connect/billing_subscription.go index 7f9425ccfa..d2b3c8a136 100644 --- a/internal/api/v1beta1connect/billing_subscription.go +++ b/internal/api/v1beta1connect/billing_subscription.go @@ -28,7 +28,7 @@ func (h *ConnectHandler) ListSubscriptions(ctx context.Context, request *connect Subscriptions: []*frontierv1beta1.Subscription{}, }), nil } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListSubscriptions.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListSubscriptions.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } billingID := cust.ID @@ -48,13 +48,13 @@ func (h *ConnectHandler) ListSubscriptions(ctx context.Context, request *connect PlanID: planID, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListSubscriptions.List: billing_id=%s org_id=%s state=%s plan_id=%s: %w", + return nil, mapBillingError(ctx, fmt.Errorf("ListSubscriptions.List: billing_id=%s org_id=%s state=%s plan_id=%s: %w", billingID, request.Msg.GetOrgId(), request.Msg.GetState(), planID, err)) } for _, v := range subscriptionList { subscriptionPB, err := transformSubscriptionToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListSubscriptions: entity_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListSubscriptions: entity_id=%s: %w", v.ID, err)) } subscriptions = append(subscriptions, subscriptionPB) } @@ -72,12 +72,12 @@ func (h *ConnectHandler) ListSubscriptions(ctx context.Context, request *connect func (h *ConnectHandler) GetSubscription(ctx context.Context, request *connect.Request[frontierv1beta1.GetSubscriptionRequest]) (*connect.Response[frontierv1beta1.GetSubscriptionResponse], error) { subscription, err := h.subscriptionService.GetByID(ctx, request.Msg.GetId()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetSubscription.GetByID: subscription_id=%s: %w", request.Msg.GetId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetSubscription.GetByID: subscription_id=%s: %w", request.Msg.GetId(), err)) } subscriptionPB, err := transformSubscriptionToPB(subscription) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("GetSubscription: entity_id=%s: %w", subscription.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("GetSubscription: entity_id=%s: %w", subscription.ID, err)) } response := &frontierv1beta1.GetSubscriptionResponse{ Subscription: subscriptionPB, @@ -92,7 +92,7 @@ func (h *ConnectHandler) GetSubscription(ctx context.Context, request *connect.R func (h *ConnectHandler) CancelSubscription(ctx context.Context, request *connect.Request[frontierv1beta1.CancelSubscriptionRequest]) (*connect.Response[frontierv1beta1.CancelSubscriptionResponse], error) { _, err := h.subscriptionService.Cancel(ctx, request.Msg.GetId(), request.Msg.GetImmediate()) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CancelSubscription.Cancel: subscription_id=%s immediate=%v: %w", request.Msg.GetId(), request.Msg.GetImmediate(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CancelSubscription.Cancel: subscription_id=%s immediate=%v: %w", request.Msg.GetId(), request.Msg.GetImmediate(), err)) } return connect.NewResponse(&frontierv1beta1.CancelSubscriptionResponse{}), nil } @@ -126,7 +126,7 @@ func (h *ConnectHandler) ChangeSubscription(ctx context.Context, request *connec if errors.Is(err, subscription.ErrAlreadyOnSamePlan) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrAlreadyOnSamePlan) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ChangeSubscription.ChangePlan: subscription_id=%s plan_id=%s immediate=%v cancel_upcoming=%v: %w", + return nil, mapBillingError(ctx, fmt.Errorf("ChangeSubscription.ChangePlan: subscription_id=%s plan_id=%s immediate=%v cancel_upcoming=%v: %w", request.Msg.GetId(), changeReq.PlanID, changeReq.Immediate, changeReq.CancelUpcoming, err)) } diff --git a/internal/api/v1beta1connect/billing_usage.go b/internal/api/v1beta1connect/billing_usage.go index a471276be4..ff7564cbf8 100644 --- a/internal/api/v1beta1connect/billing_usage.go +++ b/internal/api/v1beta1connect/billing_usage.go @@ -27,7 +27,7 @@ func (h *ConnectHandler) CreateBillingUsage(ctx context.Context, request *connec if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateBillingUsage.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("CreateBillingUsage.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } createRequests := make([]usage.Usage, 0, len(request.Msg.GetUsages())) @@ -56,7 +56,7 @@ func (h *ConnectHandler) CreateBillingUsage(ctx context.Context, request *connec if errors.Is(err, credit.ErrAlreadyApplied) { return nil, connect.NewError(connect.CodeAlreadyExists, ErrAlreadyApplied) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateBillingUsage.Report: billing_id=%s org_id=%s usage_count=%d: %w", + return nil, mapBillingError(ctx, fmt.Errorf("CreateBillingUsage.Report: billing_id=%s org_id=%s usage_count=%d: %w", cust.ID, request.Msg.GetOrgId(), len(createRequests), err)) } @@ -77,7 +77,7 @@ func (h *ConnectHandler) ListBillingTransactions(ctx context.Context, request *c if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListBillingTransactions.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListBillingTransactions.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } billingID := cust.ID @@ -101,13 +101,13 @@ func (h *ConnectHandler) ListBillingTransactions(ctx context.Context, request *c EndRange: endRange, }) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListBillingTransactions.List: org_id=%s billing_id=%s start_range=%v end_range=%v: %w", + return nil, mapBillingError(ctx, fmt.Errorf("ListBillingTransactions.List: org_id=%s billing_id=%s start_range=%v end_range=%v: %w", request.Msg.GetOrgId(), billingID, startRange, endRange, err)) } for _, v := range transactionsList { transactionPB, err := transformTransactionToPB(v) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListBillingTransactions: entity_id=%s: %w", v.ID, err)) + return nil, mapBillingError(ctx, fmt.Errorf("ListBillingTransactions: entity_id=%s: %w", v.ID, err)) } transactions = append(transactions, transactionPB) } @@ -139,13 +139,13 @@ func (h *ConnectHandler) TotalDebitedTransactions(ctx context.Context, request * if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("TotalDebitedTransactions.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("TotalDebitedTransactions.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } billingID := cust.ID debitAmount, err := h.creditService.GetTotalDebitedAmount(ctx, billingID) if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("TotalDebitedTransactions.GetTotalDebitedAmount: org_id=%s billing_id=%s: %w", + return nil, mapBillingError(ctx, fmt.Errorf("TotalDebitedTransactions.GetTotalDebitedAmount: org_id=%s billing_id=%s: %w", request.Msg.GetOrgId(), billingID, err)) } @@ -186,7 +186,7 @@ func (h *ConnectHandler) RevertBillingUsage(ctx context.Context, request *connec if errors.Is(err, customer.ErrInvalidUUID) || errors.Is(err, customer.ErrInvalidID) { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RevertBillingUsage.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("RevertBillingUsage.GetByOrgID: org_id=%s: %w", request.Msg.GetOrgId(), err)) } if err := h.usageService.Revert(ctx, cust.ID, @@ -202,7 +202,7 @@ func (h *ConnectHandler) RevertBillingUsage(ctx context.Context, request *connec } else if errors.Is(err, credit.ErrAlreadyApplied) { return nil, connect.NewError(connect.CodeInvalidArgument, err) } - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("RevertBillingUsage.Revert: billing_id=%s org_id=%s usage_id=%s amount=%d: %w", + return nil, mapBillingError(ctx, fmt.Errorf("RevertBillingUsage.Revert: billing_id=%s org_id=%s usage_id=%s amount=%d: %w", cust.ID, request.Msg.GetOrgId(), request.Msg.GetUsageId(), request.Msg.GetAmount(), err)) } return connect.NewResponse(&frontierv1beta1.RevertBillingUsageResponse{}), nil diff --git a/internal/api/v1beta1connect/billing_webhook.go b/internal/api/v1beta1connect/billing_webhook.go index bc10ce3277..5846023bc9 100644 --- a/internal/api/v1beta1connect/billing_webhook.go +++ b/internal/api/v1beta1connect/billing_webhook.go @@ -26,7 +26,7 @@ func (h *ConnectHandler) BillingWebhookCallback(ctx context.Context, request *co Name: request.Msg.GetProvider(), Body: request.Msg.GetBody(), }); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("BillingWebhookCallback.BillingWebhook: provider=%s: %w", request.Msg.GetProvider(), err)) + return nil, mapBillingError(ctx, fmt.Errorf("BillingWebhookCallback.BillingWebhook: provider=%s: %w", request.Msg.GetProvider(), err)) } return connect.NewResponse(&frontierv1beta1.BillingWebhookCallbackResponse{}), nil } diff --git a/internal/api/v1beta1connect/errors.go b/internal/api/v1beta1connect/errors.go index 4dd5890e6c..effd135b42 100644 --- a/internal/api/v1beta1connect/errors.go +++ b/internal/api/v1beta1connect/errors.go @@ -50,6 +50,8 @@ var ( ErrPerSeatLimitReached = errors.New("per seat limit reached") ErrAlreadyOnSamePlan = errors.New("already on same plan") ErrBillingProviderNotSupported = errors.New("provider not supported") + ErrBillingProviderUnavailable = errors.New("billing provider is unavailable, retry later") + ErrSubscriptionProviderMissing = errors.New("subscription no longer exists on the billing provider") ErrInsufficientCredits = errors.New("insufficient credits") ErrAlreadyApplied = errors.New("credits already applied") ErrInvalidRoleID = errors.New("role id is invalid")