diff --git a/services/skus/service.go b/services/skus/service.go index 5de20f632..0309e41b6 100644 --- a/services/skus/service.go +++ b/services/skus/service.go @@ -124,6 +124,7 @@ type stripeClient interface { Session(ctx context.Context, id string, params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) CreateSession(ctx context.Context, params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) Subscription(ctx context.Context, id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) + CancelSub(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error FindCustomer(ctx context.Context, email string) (*stripe.Customer, bool) } @@ -1666,8 +1667,11 @@ func (s *Service) processStripeNotificationTx(ctx context.Context, dbi sqlx.ExtC } paidt := time.Now() + if err := s.renewOrderStripe(ctx, dbi, ord, subID, expt, paidt); err != nil { + return err + } - return s.renewOrderStripe(ctx, dbi, ord, subID, expt, paidt) + return s.processStripeMtoA(ctx, dbi, ntf) case ntf.shouldCancel(): oid, err := ntf.orderID() @@ -2551,6 +2555,43 @@ func (s *Service) recreateStripeSession(ctx context.Context, dbi sqlx.ExecerCont return sessID, nil } +func (s *Service) processStripeMtoA(ctx context.Context, dbi sqlx.ExtContext, ntf *stripeNotification) error { + umaData, err := ntf.umaData() + if err != nil { + // Recover from the error is possible. + // Not all orders are migration orders, therefore must not fail. + if errors.Is(err, errStripeIncompleteUMAData) { + return nil + } + + return err + } + + // Cancel the order and subscription. + oid, err := uuid.FromString(umaData.orderID) + if err != nil { + return err + } + + if err := s.cancelOrderTx(ctx, dbi, oid); err != nil { + return err + } + + if err := s.stripeCl.CancelSub(ctx, umaData.stSubID, nil); err != nil { + if !isErrStripeNotFound(err) { + return err + } + } + + if !hasCxUsedUMACoupon(ntf, umaData) { + return nil + } + + // Update the customer metadata. + + return nil +} + func newOrderNewForReq(req *model.CreateOrderRequestNew, items []model.OrderItem, merchID, status string) (*model.OrderNew, error) { // Check for number of items to be above 0. // diff --git a/services/skus/service_nonint_test.go b/services/skus/service_nonint_test.go index 62974ef2e..cd2f296f7 100644 --- a/services/skus/service_nonint_test.go +++ b/services/skus/service_nonint_test.go @@ -6050,6 +6050,271 @@ func TestHandleRedeemFnError(t *testing.T) { } } +func TestService_processStripeMtoA(t *testing.T) { + type tcGiven struct { + repo *repository.MockOrder + stcl *xstripe.MockClient + ntf *stripeNotification + } + + type testCase struct { + name string + given tcGiven + exp error + } + + tests := []testCase{ + { + name: "skip_incomplete_uma_data", + given: tcGiven{ + repo: &repository.MockOrder{}, + stcl: &xstripe.MockClient{}, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + {Metadata: map[string]string{}}, + }, + }, + }, + }, + }, + }, + + { + name: "error_invalid_data", + given: tcGiven{ + repo: &repository.MockOrder{}, + stcl: &xstripe.MockClient{}, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{}, + }, + }, + }, + exp: errStripeNoInvoiceLines, + }, + + { + name: "error_invalid_order_id", + given: tcGiven{ + repo: &repository.MockOrder{}, + stcl: &xstripe.MockClient{}, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + { + Metadata: map[string]string{ + "uma__st_sub_id": "st_sub_id_01", + "uma__sub_id": "facade00-0000-4000-a000-000000000000", + "uma__order_id": "decade00-0000-4000-a000-00000000000", + }, + }, + }, + }, + }, + }, + }, + exp: errors.New("uuid: incorrect UUID length: decade00-0000-4000-a000-00000000000"), + }, + + { + name: "error_cancel_order", + given: tcGiven{ + repo: &repository.MockOrder{ + FnSetStatus: func(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, status string) error { + return model.Error("something_went_wrong") + }, + }, + stcl: &xstripe.MockClient{}, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + { + Metadata: map[string]string{ + "uma__st_sub_id": "st_sub_id_01", + "uma__sub_id": "facade00-0000-4000-a000-000000000000", + "uma__order_id": "decade00-0000-4000-a000-000000000000", + }, + }, + }, + }, + }, + }, + }, + exp: model.Error("something_went_wrong"), + }, + + { + name: "error_cancel_sub", + given: tcGiven{ + repo: &repository.MockOrder{}, + stcl: &xstripe.MockClient{ + FnCancelSub: func(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error { + return model.Error("something_went_wrong") + }, + }, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + { + Metadata: map[string]string{ + "uma__st_sub_id": "st_sub_id_01", + "uma__sub_id": "facade00-0000-4000-a000-000000000000", + "uma__order_id": "decade00-0000-4000-a000-000000000000", + }, + }, + }, + }, + }, + }, + }, + exp: model.Error("something_went_wrong"), + }, + + { + name: "success_cancel_sub_not_found_no_coupon", + given: tcGiven{ + repo: &repository.MockOrder{}, + stcl: &xstripe.MockClient{ + FnCancelSub: func(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error { + rerr := &stripe.Error{ + HTTPStatusCode: http.StatusNotFound, + Code: stripe.ErrorCodeResourceMissing, + } + + return rerr + }, + }, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + { + Metadata: map[string]string{ + "uma__st_sub_id": "st_sub_id_01", + "uma__sub_id": "facade00-0000-4000-a000-000000000000", + "uma__order_id": "decade00-0000-4000-a000-000000000000", + }, + }, + }, + }, + }, + }, + }, + }, + + { + name: "success_no_coupon", + given: tcGiven{ + repo: &repository.MockOrder{ + FnSetStatus: func(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, status string) error { + if !uuid.Equal(id, uuid.Must(uuid.FromString("decade00-0000-4000-a000-000000000000"))) { + return model.Error("unexpected_cancel_order_id") + } + + if status != model.OrderStatusCanceled { + return model.Error("unexpected_cancel_order_status") + } + + return nil + }, + }, + stcl: &xstripe.MockClient{ + FnCancelSub: func(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error { + if id != "st_sub_id_01" { + return model.Error("unexpected_cancel_sub_id") + } + + return nil + }, + }, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + { + Metadata: map[string]string{ + "uma__st_sub_id": "st_sub_id_01", + "uma__sub_id": "facade00-0000-4000-a000-000000000000", + "uma__order_id": "decade00-0000-4000-a000-000000000000", + }, + }, + }, + }, + }, + }, + }, + }, + + { + name: "success", + given: tcGiven{ + repo: &repository.MockOrder{ + FnSetStatus: func(ctx context.Context, dbi sqlx.ExecerContext, id uuid.UUID, status string) error { + if !uuid.Equal(id, uuid.Must(uuid.FromString("decade00-0000-4000-a000-000000000000"))) { + return model.Error("unexpected_cancel_order_id") + } + + if status != model.OrderStatusCanceled { + return model.Error("unexpected_cancel_order_status") + } + + return nil + }, + }, + stcl: &xstripe.MockClient{ + FnCancelSub: func(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error { + if id != "st_sub_id_01" { + return model.Error("unexpected_cancel_sub_id") + } + + return nil + }, + }, + ntf: &stripeNotification{ + invoice: &stripe.Invoice{ + Lines: &stripe.InvoiceLineList{ + Data: []*stripe.InvoiceLine{ + { + Metadata: map[string]string{ + "uma__st_sub_id": "st_sub_id_01", + "uma__sub_id": "facade00-0000-4000-a000-000000000000", + "uma__order_id": "decade00-0000-4000-a000-000000000000", + "uma__coupon_id": "coup_id_01", + }, + }, + }, + }, + Discount: &stripe.Discount{ + Coupon: &stripe.Coupon{ID: "coup_id_01"}, + }, + }, + }, + }, + }, + } + + for i := range tests { + tc := tests[i] + + t.Run(tc.name, func(t *testing.T) { + svc := &Service{ + orderRepo: tc.given.repo, + stripeCl: tc.given.stcl, + } + + ctx := context.Background() + + actual := svc.processStripeMtoA(ctx, nil, tc.given.ntf) + should.Equal(t, tc.exp, actual) + }) + } +} + type mockRadomClient struct { fnCreateCheckoutSession func(ctx context.Context, creq *radom.CheckoutSessionRequest) (radom.CheckoutSessionResponse, error) fnGetSubscription func(ctx context.Context, subID string) (*radom.SubscriptionResponse, error) diff --git a/services/skus/stripe.go b/services/skus/stripe.go index da2035c6e..9f5577a45 100644 --- a/services/skus/stripe.go +++ b/services/skus/stripe.go @@ -11,12 +11,13 @@ import ( ) const ( - errStripeSkipEvent = model.Error("stripe: skip webhook event") - errStripeUnsupportedEvent = model.Error("stripe: unsupported webhook event") - errStripeNoInvoiceSub = model.Error("stripe: no invoice subscription") - errStripeNoInvoiceLines = model.Error("stripe: no invoice lines") - errStripeOrderIDMissing = model.Error("stripe: order_id missing") - errStripeInvalidSubPeriod = model.Error("stripe: invalid subscription period") + errStripeSkipEvent = model.Error("stripe: skip webhook event") + errStripeUnsupportedEvent = model.Error("stripe: unsupported webhook event") + errStripeNoInvoiceSub = model.Error("stripe: no invoice subscription") + errStripeNoInvoiceLines = model.Error("stripe: no invoice lines") + errStripeOrderIDMissing = model.Error("stripe: order_id missing") + errStripeInvalidSubPeriod = model.Error("stripe: invalid subscription period") + errStripeIncompleteUMAData = model.Error("stripe: incomplete upgrade monthly to annual data") ) type stripeNotification struct { @@ -166,6 +167,39 @@ func (x *stripeNotification) expiresTime() (time.Time, error) { return time.Unix(sub.Period.End, 0).UTC(), nil } +func (x *stripeNotification) hasCoupon() bool { + return x.invoice != nil && x.invoice.Discount != nil && x.invoice.Discount.Coupon != nil +} + +func (x *stripeNotification) umaData() (promoMonthlyAnnualData, error) { + if x.invoice.Lines == nil || len(x.invoice.Lines.Data) == 0 { + return promoMonthlyAnnualData{}, errStripeNoInvoiceLines + } + + stSubID, ok1 := x.invoice.Lines.Data[0].Metadata["uma__st_sub_id"] + subID, ok2 := x.invoice.Lines.Data[0].Metadata["uma__sub_id"] + ordID, ok3 := x.invoice.Lines.Data[0].Metadata["uma__order_id"] + + // For MtoA, there should be all three pieces. + // Other combinations are invalid. + if ok1 && ok2 && ok3 { + result := promoMonthlyAnnualData{ + stSubID: stSubID, + subID: subID, + orderID: ordID, + } + + // Coupon is optional. + if coupID, ok := x.invoice.Lines.Data[0].Metadata["uma__coupon_id"]; ok { + result.coupID = coupID + } + + return result, nil + } + + return promoMonthlyAnnualData{}, errStripeIncompleteUMAData +} + func parseStripeEventData[T any](data []byte) (*T, error) { var result T if err := json.Unmarshal(data, &result); err != nil { @@ -174,3 +208,26 @@ func parseStripeEventData[T any](data []byte) (*T, error) { return &result, nil } + +type promoMonthlyAnnualData struct { + stSubID string + subID string + orderID string + coupID string +} + +func hasCxUsedUMACoupon(ntf *stripeNotification, promo promoMonthlyAnnualData) bool { + if !ntf.hasCoupon() { + return false + } + + if promo.coupID == "" { + return false + } + + if ntf.invoice.Discount.Coupon.ID != promo.coupID { + return false + } + + return true +} diff --git a/services/skus/stripe_test.go b/services/skus/stripe_test.go index 640fe8b9f..8520a3fbd 100644 --- a/services/skus/stripe_test.go +++ b/services/skus/stripe_test.go @@ -69,6 +69,56 @@ func TestParseStripeNotification(t *testing.T) { should.Equal(t, time.Date(2024, time.July, 12, 07, 16, 18, 0, time.UTC), time.Unix(sub.Period.End, 0).UTC()) }) + t.Run("invoice_paid_coupon", func(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("testdata", "stripe_invoice_paid_coupon.json")) + must.NoError(t, err) + + event := &stripe.Event{} + + { + err := json.Unmarshal(raw, event) + must.NoError(t, err) + } + + should.Equal(t, "invoice.paid", event.Type) + + ntf, err := parseStripeNotification(event) + must.NoError(t, err) + + should.Equal(t, "sub_1QWvtYHof20bphG6mW3XXU9c", ntf.invoice.Subscription.ID) + + must.Equal(t, 1, len(ntf.invoice.Lines.Data)) + + sub := ntf.invoice.Lines.Data[0] + + should.Equal(t, "subscription", string(sub.Type)) + should.Equal(t, "sub_1QWvtYHof20bphG6mW3XXU9c", sub.Subscription) + should.Equal(t, "a469d322-ea87-4cdb-9a09-5f67a95696ef", sub.Metadata["orderID"]) + + { + sub, err := ntf.subID() + must.NoError(t, err) + + should.Equal(t, "sub_1QWvtYHof20bphG6mW3XXU9c", sub) + } + + oid, err := ntf.orderID() + must.NoError(t, err) + + should.True(t, uuid.Equal(uuid.FromStringOrNil("a469d322-ea87-4cdb-9a09-5f67a95696ef"), oid)) + + must.True(t, sub.Period != nil) + should.Equal(t, int64(1734423164), sub.Period.Start) + should.Equal(t, time.Date(2024, time.December, 17, 8, 12, 44, 0, time.UTC), time.Unix(sub.Period.Start, 0).UTC()) + + should.Equal(t, int64(1737101564), sub.Period.End) + should.Equal(t, time.Date(2025, time.January, 17, 8, 12, 44, 0, time.UTC), time.Unix(sub.Period.End, 0).UTC()) + + must.Equal(t, 1, len(ntf.invoice.Discounts)) + must.True(t, ntf.invoice.Discount != nil) + should.Equal(t, "g4V47kwV", ntf.invoice.Discount.Coupon.ID) + }) + t.Run("invoice_payment_failed", func(t *testing.T) { raw, err := os.ReadFile(filepath.Join("testdata", "stripe_invoice_payment_failed.json")) must.NoError(t, err) diff --git a/services/skus/testdata/stripe_invoice_paid_coupon.json b/services/skus/testdata/stripe_invoice_paid_coupon.json new file mode 100644 index 000000000..35e71a86d --- /dev/null +++ b/services/skus/testdata/stripe_invoice_paid_coupon.json @@ -0,0 +1,279 @@ +{ + "id": "evt_1QWvtcHof20bphG6UPhbXh0t", + "object": "event", + "api_version": "2020-08-27", + "created": 1734423167, + "data": { + "object": { + "id": "in_1QWvtYHof20bphG6WUDSRwo9", + "object": "invoice", + "account_country": "US", + "account_name": "Brave Software", + "account_tax_ids": null, + "amount_due": 1349, + "amount_paid": 1349, + "amount_remaining": 0, + "amount_shipping": 0, + "application": null, + "application_fee_amount": null, + "attempt_count": 1, + "attempted": true, + "auto_advance": false, + "automatic_tax": { + "disabled_reason": null, + "enabled": false, + "liability": null, + "status": null + }, + "automatically_finalizes_at": null, + "billing_reason": "subscription_create", + "charge": "ch_3QWvtZHof20bphG61P9JYpGt", + "collection_method": "charge_automatically", + "created": 1734423165, + "currency": "usd", + "custom_fields": null, + "customer": "cus_RPlQ4qhcgg3uin", + "customer_address": { + "city": null, + "country": "AU", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "customer_email": "c0c0a000-0000-4000-a000-000000000000@mailinator.com", + "customer_name": "Cocoa", + "customer_phone": null, + "customer_shipping": null, + "customer_tax_exempt": "none", + "customer_tax_ids": [], + "default_payment_method": null, + "default_source": null, + "default_tax_rates": [], + "description": null, + "discount": { + "id": "di_1QWvtYHof20bphG6uFdxEUZ2", + "object": "discount", + "checkout_session": null, + "coupon": { + "id": "g4V47kwV", + "object": "coupon", + "amount_off": null, + "created": 1734006059, + "currency": null, + "duration": "once", + "duration_in_months": null, + "livemode": false, + "max_redemptions": null, + "metadata": {}, + "name": "Upgrading to Brave Leo Annual", + "percent_off": 10, + "redeem_by": null, + "times_redeemed": 1, + "valid": true + }, + "customer": "cus_RPlQ4qhcgg3uin", + "end": null, + "invoice": null, + "invoice_item": null, + "promotion_code": null, + "start": 1734423164, + "subscription": "sub_1QWvtYHof20bphG6mW3XXU9c", + "subscription_item": null + }, + "discounts": [ + "di_1QWvtYHof20bphG6uFdxEUZ2" + ], + "due_date": null, + "effective_at": 1734423165, + "ending_balance": 0, + "footer": null, + "from_invoice": null, + "hosted_invoice_url": "https://invoice.stripe.com/i/acct_1HlmudHof20bphG6/test_YWNjdF8xSGxtdWRIb2YyMGJwaEc2LF9SUGxRVEVRRllaNldSRkpNSk1vSG5IYmJQMHg3dXhWLDEyNDk2Mzk2OA0200gBRyEpJ6?s=ap", + "invoice_pdf": "https://pay.stripe.com/invoice/acct_1HlmudHof20bphG6/test_YWNjdF8xSGxtdWRIb2YyMGJwaEc2LF9SUGxRVEVRRllaNldSRkpNSk1vSG5IYmJQMHg3dXhWLDEyNDk2Mzk2OA0200gBRyEpJ6/pdf?s=ap", + "issuer": { + "type": "self" + }, + "last_finalization_error": null, + "latest_revision": null, + "lines": { + "object": "list", + "data": [ + { + "id": "il_1QWvtYHof20bphG60jmJRiTn", + "object": "line_item", + "amount": 1499, + "amount_excluding_tax": 1499, + "currency": "usd", + "description": "1 × Brave Leo (at $14.99 / month)", + "discount_amounts": [ + { + "amount": 150, + "discount": "di_1QWvtYHof20bphG6uFdxEUZ2" + } + ], + "discountable": true, + "discounts": [], + "invoice": "in_1QWvtYHof20bphG6WUDSRwo9", + "livemode": false, + "metadata": { + "orderID": "a469d322-ea87-4cdb-9a09-5f67a95696ef" + }, + "period": { + "end": 1737101564, + "start": 1734423164 + }, + "plan": { + "id": "price_1OuRqmHof20bphG6RXl7EHP2", + "object": "plan", + "active": true, + "aggregate_usage": null, + "amount": 1499, + "amount_decimal": "1499", + "billing_scheme": "per_unit", + "created": 1710474872, + "currency": "usd", + "interval": "month", + "interval_count": 1, + "livemode": false, + "metadata": {}, + "meter": null, + "nickname": null, + "product": "prod_OtZCXOCIO3AJE6", + "tiers_mode": null, + "transform_usage": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "pretax_credit_amounts": [ + { + "amount": 150, + "discount": "di_1QWvtYHof20bphG6uFdxEUZ2", + "type": "discount" + } + ], + "price": { + "id": "price_1OuRqmHof20bphG6RXl7EHP2", + "object": "price", + "active": true, + "billing_scheme": "per_unit", + "created": 1710474872, + "currency": "usd", + "custom_unit_amount": null, + "livemode": false, + "lookup_key": null, + "metadata": {}, + "nickname": null, + "product": "prod_OtZCXOCIO3AJE6", + "recurring": { + "aggregate_usage": null, + "interval": "month", + "interval_count": 1, + "meter": null, + "trial_period_days": null, + "usage_type": "licensed" + }, + "tax_behavior": "unspecified", + "tiers_mode": null, + "transform_quantity": null, + "type": "recurring", + "unit_amount": 1499, + "unit_amount_decimal": "1499" + }, + "proration": false, + "proration_details": { + "credited_items": null + }, + "quantity": 1, + "subscription": "sub_1QWvtYHof20bphG6mW3XXU9c", + "subscription_item": "si_RPlQE1xS1nhEpF", + "tax_amounts": [], + "tax_rates": [], + "type": "subscription", + "unit_amount_excluding_tax": "1499" + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/invoices/in_1QWvtYHof20bphG6WUDSRwo9/lines" + }, + "livemode": false, + "metadata": {}, + "next_payment_attempt": null, + "number": "6F05F825-0001", + "on_behalf_of": null, + "paid": true, + "paid_out_of_band": false, + "payment_intent": "pi_3QWvtZHof20bphG61lcaqavM", + "payment_settings": { + "default_mandate": null, + "payment_method_options": { + "acss_debit": null, + "bancontact": null, + "card": { + "request_three_d_secure": "automatic" + }, + "customer_balance": null, + "konbini": null, + "sepa_debit": null, + "us_bank_account": null + }, + "payment_method_types": null + }, + "period_end": 1734423164, + "period_start": 1734423164, + "post_payment_credit_notes_amount": 0, + "pre_payment_credit_notes_amount": 0, + "quote": null, + "receipt_number": null, + "rendering": null, + "rendering_options": null, + "shipping_cost": null, + "shipping_details": null, + "starting_balance": 0, + "statement_descriptor": null, + "status": "paid", + "status_transitions": { + "finalized_at": 1734423165, + "marked_uncollectible_at": null, + "paid_at": 1734423167, + "voided_at": null + }, + "subscription": "sub_1QWvtYHof20bphG6mW3XXU9c", + "subscription_details": { + "metadata": { + "orderID": "a469d322-ea87-4cdb-9a09-5f67a95696ef" + } + }, + "subtotal": 1499, + "subtotal_excluding_tax": 1499, + "tax": null, + "test_clock": null, + "total": 1349, + "total_discount_amounts": [ + { + "amount": 150, + "discount": "di_1QWvtYHof20bphG6uFdxEUZ2" + } + ], + "total_excluding_tax": 1349, + "total_pretax_credit_amounts": [ + { + "amount": 150, + "discount": "di_1QWvtYHof20bphG6uFdxEUZ2", + "type": "discount" + } + ], + "total_tax_amounts": [], + "transfer_data": null, + "webhooks_delivered_at": 1734423165 + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": "req_8u5iYdHnFHcaE6", + "idempotency_key": "98bf3643-115c-457a-b324-041820d8b08c" + }, + "type": "invoice.paid" +} diff --git a/services/skus/xstripe/mock.go b/services/skus/xstripe/mock.go index 803eb4943..155063ebe 100644 --- a/services/skus/xstripe/mock.go +++ b/services/skus/xstripe/mock.go @@ -10,6 +10,7 @@ type MockClient struct { FnSession func(ctx context.Context, id string, params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) FnCreateSession func(ctx context.Context, params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error) FnSubscription func(ctx context.Context, id string, params *stripe.SubscriptionParams) (*stripe.Subscription, error) + FnCancelSub func(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error FnFindCustomer func(ctx context.Context, email string) (*stripe.Customer, bool) } @@ -62,6 +63,14 @@ func (c *MockClient) Subscription(ctx context.Context, id string, params *stripe return c.FnSubscription(ctx, id, params) } +func (c *MockClient) CancelSub(ctx context.Context, id string, params *stripe.SubscriptionCancelParams) error { + if c.FnCancelSub == nil { + return nil + } + + return c.FnCancelSub(ctx, id, params) +} + func (c *MockClient) FindCustomer(ctx context.Context, email string) (*stripe.Customer, bool) { if c.FnFindCustomer == nil { result := &stripe.Customer{ diff --git a/services/skus/xstripe/xstripe.go b/services/skus/xstripe/xstripe.go index e9f2a5580..65dc556f8 100644 --- a/services/skus/xstripe/xstripe.go +++ b/services/skus/xstripe/xstripe.go @@ -28,6 +28,12 @@ func (c *Client) Subscription(_ context.Context, id string, params *stripe.Subsc return c.cl.Subscriptions.Get(id, params) } +func (c *Client) CancelSub(_ context.Context, id string, params *stripe.SubscriptionCancelParams) error { + _, err := c.cl.Subscriptions.Cancel(id, params) + + return err +} + func (c *Client) FindCustomer(ctx context.Context, email string) (*stripe.Customer, bool) { iter := c.Customers(ctx, &stripe.CustomerListParams{Email: &email})