Skip to content

Commit d0886c8

Browse files
turipclaude
andauthored
feat: implement external payment endpoints (#4158)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6163a01 commit d0886c8

13 files changed

Lines changed: 283 additions & 34 deletions

File tree

api/v3/handlers/customers/credits/convert.go

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ import (
1919
"github.com/openmeterio/openmeter/pkg/models"
2020
)
2121

22-
func convertCreditGrant(charge creditpurchase.Charge) (api.BillingCreditGrant, error) {
22+
func toAPIBillingCreditGrant(charge creditpurchase.Charge) (api.BillingCreditGrant, error) {
2323
grant := api.BillingCreditGrant{
2424
Id: charge.ID,
2525
Name: charge.Intent.Name,
2626
Description: charge.Intent.Description,
2727
Amount: charge.Intent.CreditAmount.String(),
2828
Currency: api.BillingCurrencyCode(charge.Intent.Currency),
29-
FundingMethod: convertFundingMethod(charge.Intent.Settlement),
30-
Status: convertGrantStatus(charge),
29+
FundingMethod: toAPIBillingCreditFundingMethod(charge.Intent.Settlement),
30+
Status: toAPIBillingCreditGrantStatus(charge),
3131
CreatedAt: lo.ToPtr(charge.CreatedAt),
3232
UpdatedAt: lo.ToPtr(charge.UpdatedAt),
3333
DeletedAt: charge.DeletedAt,
@@ -39,17 +39,17 @@ func convertCreditGrant(charge creditpurchase.Charge) (api.BillingCreditGrant, e
3939
grant.Priority = &p
4040
}
4141

42-
purchase, err := convertPurchase(charge)
42+
purchase, err := toAPICreditGrantPurchase(charge)
4343
if err != nil {
4444
return grant, fmt.Errorf("converting purchase: %w", err)
4545
}
4646
grant.Purchase = purchase
47-
grant.TaxConfig = convertTaxConfig(charge)
47+
grant.TaxConfig = toAPIBillingCreditGrantTaxConfig(charge)
4848

4949
return grant, nil
5050
}
5151

52-
func convertFundingMethod(settlement creditpurchase.Settlement) api.BillingCreditFundingMethod {
52+
func toAPIBillingCreditFundingMethod(settlement creditpurchase.Settlement) api.BillingCreditFundingMethod {
5353
switch settlement.Type() {
5454
case creditpurchase.SettlementTypeInvoice:
5555
return api.BillingCreditFundingMethodInvoice
@@ -60,7 +60,7 @@ func convertFundingMethod(settlement creditpurchase.Settlement) api.BillingCredi
6060
}
6161
}
6262

63-
func convertGrantStatus(charge creditpurchase.Charge) api.BillingCreditGrantStatus {
63+
func toAPIBillingCreditGrantStatus(charge creditpurchase.Charge) api.BillingCreditGrantStatus {
6464
switch charge.Status {
6565
case creditpurchase.StatusActive, creditpurchase.StatusFinal:
6666
return api.BillingCreditGrantStatusActive
@@ -81,9 +81,9 @@ type creditGrantPurchase = struct {
8181
SettlementStatus *api.BillingCreditPurchasePaymentSettlementStatus `json:"settlement_status,omitempty"`
8282
}
8383

84-
// convertPurchase builds the purchase block for funded grants (invoice or external).
84+
// toAPICreditGrantPurchase builds the purchase block for funded grants (invoice or external).
8585
// Returns nil for promotional grants (funding_method=none).
86-
func convertPurchase(charge creditpurchase.Charge) (*creditGrantPurchase, error) {
86+
func toAPICreditGrantPurchase(charge creditpurchase.Charge) (*creditGrantPurchase, error) {
8787
settlement := charge.Intent.Settlement
8888

8989
switch settlement.Type() {
@@ -102,7 +102,7 @@ func convertPurchase(charge creditpurchase.Charge) (*creditGrantPurchase, error)
102102
settlementStatus := api.BillingCreditPurchasePaymentSettlementStatusPending
103103

104104
if charge.Realizations.InvoiceSettlement != nil {
105-
settlementStatus = convertPaymentStatus(charge.Realizations.InvoiceSettlement.Status)
105+
settlementStatus = toAPIBillingCreditPurchasePaymentSettlementStatus(charge.Realizations.InvoiceSettlement.Status)
106106
}
107107

108108
return &creditGrantPurchase{
@@ -124,14 +124,14 @@ func convertPurchase(charge creditpurchase.Charge) (*creditGrantPurchase, error)
124124
return nil, fmt.Errorf("getting currency calculator: %w", err)
125125
}
126126
purchaseAmount := currencyCalculator.RoundToPrecision(charge.Intent.CreditAmount.Mul(ext.CostBasis))
127-
availPolicy, err := convertAvailabilityPolicy(ext.InitialStatus)
127+
availPolicy, err := toAPIBillingCreditAvailabilityPolicy(ext.InitialStatus)
128128
if err != nil {
129129
return nil, fmt.Errorf("converting availability policy: %w", err)
130130
}
131131
settlementStatus := api.BillingCreditPurchasePaymentSettlementStatusPending
132132

133133
if charge.Realizations.ExternalPaymentSettlement != nil {
134-
settlementStatus = convertPaymentStatus(charge.Realizations.ExternalPaymentSettlement.Status)
134+
settlementStatus = toAPIBillingCreditPurchasePaymentSettlementStatus(charge.Realizations.ExternalPaymentSettlement.Status)
135135
}
136136

137137
return &creditGrantPurchase{
@@ -147,7 +147,7 @@ func convertPurchase(charge creditpurchase.Charge) (*creditGrantPurchase, error)
147147
}
148148
}
149149

150-
func convertPaymentStatus(status payment.Status) api.BillingCreditPurchasePaymentSettlementStatus {
150+
func toAPIBillingCreditPurchasePaymentSettlementStatus(status payment.Status) api.BillingCreditPurchasePaymentSettlementStatus {
151151
switch status {
152152
case payment.StatusAuthorized:
153153
return api.BillingCreditPurchasePaymentSettlementStatusAuthorized
@@ -158,7 +158,7 @@ func convertPaymentStatus(status payment.Status) api.BillingCreditPurchasePaymen
158158
}
159159
}
160160

161-
func convertAvailabilityPolicy(status creditpurchase.InitialPaymentSettlementStatus) (api.BillingCreditAvailabilityPolicy, error) {
161+
func toAPIBillingCreditAvailabilityPolicy(status creditpurchase.InitialPaymentSettlementStatus) (api.BillingCreditAvailabilityPolicy, error) {
162162
switch status {
163163
case creditpurchase.CreatedInitialPaymentSettlementStatus:
164164
return api.BillingCreditAvailabilityPolicyOnCreation, nil
@@ -167,7 +167,7 @@ func convertAvailabilityPolicy(status creditpurchase.InitialPaymentSettlementSta
167167
}
168168
}
169169

170-
func convertTaxConfig(charge creditpurchase.Charge) *api.BillingCreditGrantTaxConfig {
170+
func toAPIBillingCreditGrantTaxConfig(charge creditpurchase.Charge) *api.BillingCreditGrantTaxConfig {
171171
if charge.Intent.TaxConfig == nil {
172172
return nil
173173
}
@@ -190,7 +190,7 @@ func convertTaxConfig(charge creditpurchase.Charge) *api.BillingCreditGrantTaxCo
190190
return tc
191191
}
192192

193-
func convertAPIFundingMethod(fm api.BillingCreditFundingMethod) creditgrant.FundingMethod {
193+
func fromAPIBillingCreditFundingMethod(fm api.BillingCreditFundingMethod) creditgrant.FundingMethod {
194194
switch fm {
195195
case api.BillingCreditFundingMethodInvoice:
196196
return creditgrant.FundingMethodInvoice
@@ -201,7 +201,7 @@ func convertAPIFundingMethod(fm api.BillingCreditFundingMethod) creditgrant.Fund
201201
}
202202
}
203203

204-
func convertAPIAvailabilityPolicy(policy api.BillingCreditAvailabilityPolicy) (creditpurchase.InitialPaymentSettlementStatus, error) {
204+
func fromAPIBillingCreditAvailabilityPolicy(policy api.BillingCreditAvailabilityPolicy) (creditpurchase.InitialPaymentSettlementStatus, error) {
205205
switch policy {
206206
case api.BillingCreditAvailabilityPolicyOnCreation:
207207
return creditpurchase.CreatedInitialPaymentSettlementStatus, nil
@@ -210,7 +210,7 @@ func convertAPIAvailabilityPolicy(policy api.BillingCreditAvailabilityPolicy) (c
210210
}
211211
}
212212

213-
func convertAPITaxConfig(tc *api.BillingCreditGrantTaxConfig) *productcatalog.TaxConfig {
213+
func fromAPIBillingCreditGrantTaxConfig(tc *api.BillingCreditGrantTaxConfig) *productcatalog.TaxConfig {
214214
if tc == nil {
215215
return nil
216216
}
@@ -229,7 +229,7 @@ func convertAPITaxConfig(tc *api.BillingCreditGrantTaxConfig) *productcatalog.Ta
229229
return config
230230
}
231231

232-
func convertAPIStatusToChargeStatus(status api.BillingCreditGrantStatus) (meta.ChargeStatus, error) {
232+
func fromAPIBillingCreditGrantStatus(status api.BillingCreditGrantStatus) (meta.ChargeStatus, error) {
233233
switch status {
234234
case api.BillingCreditGrantStatusActive:
235235
return meta.ChargeStatusActive, nil
@@ -245,7 +245,7 @@ func convertAPIStatusToChargeStatus(status api.BillingCreditGrantStatus) (meta.C
245245
}
246246
}
247247

248-
func convertAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api.CreateCreditGrantRequest) (creditgrant.CreateInput, error) {
248+
func fromAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api.CreateCreditGrantRequest) (creditgrant.CreateInput, error) {
249249
amount, err := alpacadecimal.NewFromString(body.Amount)
250250
if err != nil {
251251
return creditgrant.CreateInput{}, fmt.Errorf("invalid amount: %w", err)
@@ -258,7 +258,7 @@ func convertAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api
258258
Description: body.Description,
259259
Currency: currencyx.Code(body.Currency),
260260
Amount: amount,
261-
FundingMethod: convertAPIFundingMethod(body.FundingMethod),
261+
FundingMethod: fromAPIBillingCreditFundingMethod(body.FundingMethod),
262262
Priority: body.Priority,
263263
Labels: lo.FromPtrOr(body.Labels, api.Labels{}),
264264
}
@@ -278,7 +278,7 @@ func convertAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api
278278
}
279279

280280
if body.Purchase.AvailabilityPolicy != nil {
281-
policy, err := convertAPIAvailabilityPolicy(*body.Purchase.AvailabilityPolicy)
281+
policy, err := fromAPIBillingCreditAvailabilityPolicy(*body.Purchase.AvailabilityPolicy)
282282
if err != nil {
283283
return creditgrant.CreateInput{}, err
284284
}
@@ -289,7 +289,7 @@ func convertAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api
289289
}
290290

291291
if body.TaxConfig != nil {
292-
req.TaxConfig = convertAPITaxConfig(body.TaxConfig)
292+
req.TaxConfig = fromAPIBillingCreditGrantTaxConfig(body.TaxConfig)
293293
}
294294

295295
if body.Filters != nil && body.Filters.Features != nil && len(*body.Filters.Features) > 0 {
@@ -299,7 +299,37 @@ func convertAPICreateCreditGrantRequest(ns string, customerID api.ULID, body api
299299
return req, nil
300300
}
301301

302-
func convertBalance(currency currencyx.Code, balance ledger.Balance) api.CreditBalance {
302+
func fromAPIUpdateCreditGrantExternalSettlementRequest(
303+
ns string,
304+
customerID api.ULID,
305+
creditGrantID api.ULID,
306+
body api.UpdateCreditGrantExternalSettlementRequest,
307+
) (creditgrant.UpdateExternalSettlementInput, error) {
308+
targetStatus, err := fromAPIBillingCreditPurchasePaymentSettlementStatus(body.Status)
309+
if err != nil {
310+
return creditgrant.UpdateExternalSettlementInput{}, err
311+
}
312+
313+
return creditgrant.UpdateExternalSettlementInput{
314+
Namespace: ns,
315+
CustomerID: customerID,
316+
ChargeID: creditGrantID,
317+
TargetStatus: targetStatus,
318+
}, nil
319+
}
320+
321+
func fromAPIBillingCreditPurchasePaymentSettlementStatus(status api.BillingCreditPurchasePaymentSettlementStatus) (payment.Status, error) {
322+
switch status {
323+
case api.BillingCreditPurchasePaymentSettlementStatusAuthorized:
324+
return payment.StatusAuthorized, nil
325+
case api.BillingCreditPurchasePaymentSettlementStatusSettled:
326+
return payment.StatusSettled, nil
327+
default:
328+
return "", newCreditGrantExternalSettlementStatusInvalid(string(status))
329+
}
330+
}
331+
332+
func toAPICreditBalance(currency currencyx.Code, balance ledger.Balance) api.CreditBalance {
303333
// Temporary mapping while the v3 credit-balance schema still predates the
304334
// customerbalance service's settled/live-pending semantics.
305335
return api.CreditBalance{

api/v3/handlers/customers/credits/create_grant.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (h *handler) CreateCreditGrant() CreateCreditGrantHandler {
3434
return CreateCreditGrantRequest{}, err
3535
}
3636

37-
req, err := convertAPICreateCreditGrantRequest(ns, args.CustomerID, body)
37+
req, err := fromAPICreateCreditGrantRequest(ns, args.CustomerID, body)
3838
if err != nil {
3939
return CreateCreditGrantRequest{}, apierrors.NewBadRequestError(ctx, err, nil)
4040
}
@@ -47,7 +47,7 @@ func (h *handler) CreateCreditGrant() CreateCreditGrantHandler {
4747
return CreateCreditGrantResponse{}, err
4848
}
4949

50-
return convertCreditGrant(charge)
50+
return toAPIBillingCreditGrant(charge)
5151
},
5252
commonhttp.JSONResponseEncoderWithStatus[CreateCreditGrantResponse](http.StatusCreated),
5353
httptransport.AppendOptions(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package customerscredits
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
7+
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
8+
"github.com/openmeterio/openmeter/pkg/models"
9+
)
10+
11+
const errCodeCreditGrantExternalSettlementStatusInvalid models.ErrorCode = "credit_grant_external_settlement_status_invalid"
12+
13+
func newCreditGrantExternalSettlementStatusInvalid(status string) error {
14+
return models.NewValidationIssue(
15+
errCodeCreditGrantExternalSettlementStatusInvalid,
16+
fmt.Sprintf("unsupported credit grant settlement status: %s", status),
17+
models.WithCriticalSeverity(),
18+
commonhttp.WithHTTPStatusCodeAttribute(http.StatusBadRequest),
19+
models.WithFieldString("status"),
20+
models.WithAttribute("status", status),
21+
)
22+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package customerscredits
2+
3+
import (
4+
"context"
5+
"net/http"
6+
7+
api "github.com/openmeterio/openmeter/api/v3"
8+
"github.com/openmeterio/openmeter/api/v3/apierrors"
9+
"github.com/openmeterio/openmeter/api/v3/request"
10+
"github.com/openmeterio/openmeter/openmeter/billing/creditgrant"
11+
"github.com/openmeterio/openmeter/pkg/framework/commonhttp"
12+
"github.com/openmeterio/openmeter/pkg/framework/transport/httptransport"
13+
)
14+
15+
type (
16+
UpdateCreditGrantExternalSettlementRequest = creditgrant.UpdateExternalSettlementInput
17+
UpdateCreditGrantExternalSettlementResponse = api.BillingCreditGrant
18+
UpdateCreditGrantExternalSettlementParams struct {
19+
CustomerID api.ULID
20+
CreditGrantID api.ULID
21+
}
22+
UpdateCreditGrantExternalSettlementHandler = httptransport.HandlerWithArgs[UpdateCreditGrantExternalSettlementRequest, UpdateCreditGrantExternalSettlementResponse, UpdateCreditGrantExternalSettlementParams]
23+
)
24+
25+
func (h *handler) UpdateCreditGrantExternalSettlement() UpdateCreditGrantExternalSettlementHandler {
26+
return httptransport.NewHandlerWithArgs(
27+
func(ctx context.Context, r *http.Request, args UpdateCreditGrantExternalSettlementParams) (UpdateCreditGrantExternalSettlementRequest, error) {
28+
ns, err := h.resolveNamespace(ctx)
29+
if err != nil {
30+
return UpdateCreditGrantExternalSettlementRequest{}, err
31+
}
32+
33+
var body api.UpdateCreditGrantExternalSettlementRequest
34+
if err := request.ParseBody(r, &body); err != nil {
35+
return UpdateCreditGrantExternalSettlementRequest{}, err
36+
}
37+
38+
req, err := fromAPIUpdateCreditGrantExternalSettlementRequest(ns, args.CustomerID, args.CreditGrantID, body)
39+
if err != nil {
40+
return UpdateCreditGrantExternalSettlementRequest{}, err
41+
}
42+
43+
return req, nil
44+
},
45+
func(ctx context.Context, request UpdateCreditGrantExternalSettlementRequest) (UpdateCreditGrantExternalSettlementResponse, error) {
46+
charge, err := h.creditGrantService.UpdateExternalSettlement(ctx, request)
47+
if err != nil {
48+
return UpdateCreditGrantExternalSettlementResponse{}, err
49+
}
50+
51+
return toAPIBillingCreditGrant(charge)
52+
},
53+
commonhttp.JSONResponseEncoderWithStatus[UpdateCreditGrantExternalSettlementResponse](http.StatusOK),
54+
httptransport.AppendOptions(
55+
h.options,
56+
httptransport.WithOperationName("update-credit-grant-external-settlement"),
57+
httptransport.WithErrorEncoder(apierrors.GenericErrorEncoder()),
58+
)...,
59+
)
60+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package customerscredits
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
11+
api "github.com/openmeterio/openmeter/api/v3"
12+
"github.com/openmeterio/openmeter/api/v3/apierrors"
13+
"github.com/openmeterio/openmeter/pkg/models"
14+
)
15+
16+
func TestConvertAPIUpdateCreditGrantExternalSettlementRequest(t *testing.T) {
17+
req, err := fromAPIUpdateCreditGrantExternalSettlementRequest(
18+
"ns",
19+
"cust-1",
20+
"grant-1",
21+
api.UpdateCreditGrantExternalSettlementRequest{
22+
Status: api.BillingCreditPurchasePaymentSettlementStatusAuthorized,
23+
},
24+
)
25+
26+
require.NoError(t, err)
27+
require.Equal(t, "ns", req.Namespace)
28+
require.Equal(t, "cust-1", req.CustomerID)
29+
require.Equal(t, "grant-1", req.ChargeID)
30+
require.Equal(t, "authorized", string(req.TargetStatus))
31+
}
32+
33+
func TestConvertAPIUpdateCreditGrantExternalSettlementRequestRejectsPending(t *testing.T) {
34+
_, err := fromAPIUpdateCreditGrantExternalSettlementRequest(
35+
"ns",
36+
"cust-1",
37+
"grant-1",
38+
api.UpdateCreditGrantExternalSettlementRequest{
39+
Status: api.BillingCreditPurchasePaymentSettlementStatusPending,
40+
},
41+
)
42+
43+
require.Error(t, err)
44+
issues, convErr := models.AsValidationIssues(err)
45+
require.NoError(t, convErr)
46+
require.Len(t, issues, 1)
47+
require.ErrorContains(t, err, "unsupported credit grant settlement status")
48+
49+
rec := httptest.NewRecorder()
50+
req := httptest.NewRequest(http.MethodPost, "/api/v3/customers/cust-1/credits/grant-1/external-settlement", nil)
51+
handled := apierrors.GenericErrorEncoder()(context.Background(), err, rec, req)
52+
53+
require.True(t, handled)
54+
require.Equal(t, http.StatusBadRequest, rec.Code)
55+
}

0 commit comments

Comments
 (0)