diff --git a/internal/commands/checkouts/checkouts.go b/internal/commands/checkouts/checkouts.go index 0726432..8bc87b1 100644 --- a/internal/commands/checkouts/checkouts.go +++ b/internal/commands/checkouts/checkouts.go @@ -9,6 +9,7 @@ import ( "github.com/urfave/cli/v3" sumup "github.com/sumup/sumup-go" + "github.com/sumup/sumup-go/datetime" "github.com/sumup/sumup-cli/internal/app" "github.com/sumup/sumup-cli/internal/commands/util" @@ -90,6 +91,54 @@ func NewCommand() *cli.Command { Action: deactivateCheckout, ArgsUsage: "", }, + { + Name: "get", + Usage: "Get a checkout by ID.", + Action: getCheckout, + ArgsUsage: "", + }, + { + Name: "payment-methods", + Usage: "List available payment methods for a merchant.", + Action: listPaymentMethods, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "merchant-code", + Usage: "Merchant code that owns the checkout. Falls back to context.", + Sources: cli.EnvVars("SUMUP_MERCHANT_CODE"), + }, + &cli.Float64Flag{ + Name: "amount", + Usage: "Optional amount filter.", + }, + &cli.StringFlag{ + Name: "currency", + Usage: fmt.Sprintf("Optional currency filter. Supported: %s", strings.Join(currency.Supported(), ", ")), + }, + }, + }, + { + Name: "process", + Usage: "Process a checkout.", + Action: processCheckout, + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "payment-type", + Usage: "Payment type to use when processing the checkout.", + Required: true, + }, + &cli.StringFlag{Name: "customer-id", Usage: "Customer ID for tokenized payments."}, + &cli.StringFlag{Name: "token", Usage: "Saved payment instrument token."}, + &cli.IntFlag{Name: "installments", Usage: "Installment count for supported regions."}, + &cli.StringFlag{Name: "first-name", Usage: "Customer first name."}, + &cli.StringFlag{Name: "last-name", Usage: "Customer last name."}, + &cli.StringFlag{Name: "email", Usage: "Customer email."}, + &cli.StringFlag{Name: "phone", Usage: "Customer phone."}, + &cli.StringFlag{Name: "tax-id", Usage: "Customer tax ID."}, + &cli.StringFlag{Name: "birth-date", Usage: "Customer birth date in YYYY-MM-DD format."}, + }, + }, }, } } @@ -238,3 +287,196 @@ func deactivateCheckout(ctx context.Context, cmd *cli.Command) error { display.DataList(details) return nil } + +func getCheckout(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + checkoutID, err := util.RequireSingleArg(cmd, "checkout ID") + if err != nil { + return err + } + + checkout, err := appCtx.Client.Checkouts.Get(ctx, checkoutID) + if err != nil { + return fmt.Errorf("get checkout: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(checkout) + } + + renderCheckout(appCtx, checkout) + return nil +} + +func listPaymentMethods(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + merchantCode, err := app.GetMerchantCode(cmd, "merchant-code") + if err != nil { + return err + } + + params := sumup.CheckoutsListAvailablePaymentMethodsParams{} + if cmd.IsSet("amount") { + value := cmd.Float64("amount") + params.Amount = &value + } + if value := cmd.String("currency"); value != "" { + parsedCurrency, err := currency.Parse(value) + if err != nil { + return err + } + c := string(parsedCurrency) + params.Currency = &c + } + + methods, err := appCtx.Client.Checkouts.ListAvailablePaymentMethods(ctx, merchantCode, params) + if err != nil { + return fmt.Errorf("list checkout payment methods: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(methods.AvailablePaymentMethods) + } + + rows := make([][]attribute.Value, 0, len(methods.AvailablePaymentMethods)) + for _, method := range methods.AvailablePaymentMethods { + rows = append(rows, []attribute.Value{attribute.ValueOf(method.ID)}) + } + + display.RenderTable("Checkout Payment Methods", []string{"ID"}, rows) + return nil +} + +func processCheckout(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + checkoutID, err := util.RequireSingleArg(cmd, "checkout ID") + if err != nil { + return err + } + + body := sumup.CheckoutsProcessParams{ + PaymentType: sumup.ProcessCheckoutPaymentType(cmd.String("payment-type")), + } + if customerID := cmd.String("customer-id"); customerID != "" { + body.CustomerID = &customerID + } + if token := cmd.String("token"); token != "" { + body.Token = &token + } + if cmd.IsSet("installments") { + value := cmd.Int("installments") + body.Installments = &value + } + if details, changedCount, err := checkoutPersonalDetailsFromFlags(cmd); err != nil { + return err + } else if changedCount > 0 { + body.PersonalDetails = details + } + + response, err := appCtx.Client.Checkouts.Process(ctx, checkoutID, body) + if err != nil { + return fmt.Errorf("process checkout: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(response) + } + + if response.CheckoutSuccess != nil { + message.Success("Checkout processed") + renderCheckout(appCtx, response.CheckoutSuccess) + return nil + } + + if response.CheckoutAccepted != nil { + message.Success("Checkout accepted") + if response.CheckoutAccepted.NextStep != nil { + display.DataList([]attribute.KeyValue{ + attribute.OptionalString("Method", response.CheckoutAccepted.NextStep.Method), + attribute.OptionalString("URL", response.CheckoutAccepted.NextStep.URL), + attribute.OptionalString("Redirect URL", response.CheckoutAccepted.NextStep.RedirectURL), + }) + } + } + return nil +} + +func renderCheckout(appCtx *app.Context, checkout *sumup.CheckoutSuccess) { + if checkout == nil { + return + } + + details := []attribute.KeyValue{} + if checkout.ID != nil { + details = append(details, attribute.ID(*checkout.ID)) + } + details = append(details, + attribute.Attribute("Reference", attribute.Styled(util.StringOrDefault(checkout.CheckoutReference, "-"))), + attribute.Attribute("Amount", attribute.Styled(currency.FormatPointers(checkout.Amount, checkout.Currency))), + attribute.OptionalString("Merchant", checkout.MerchantCode), + attribute.OptionalString("Merchant Name", checkout.MerchantName), + attribute.OptionalString("Description", checkout.Description), + attribute.OptionalString("Status", enumString(checkout.Status)), + attribute.OptionalString("Transaction ID", checkout.TransactionID), + attribute.OptionalString("Transaction Code", checkout.TransactionCode), + attribute.Attribute("Created At", attribute.Styled(util.TimeOrDash(appCtx, checkout.Date))), + ) + display.DataList(details) +} + +func checkoutPersonalDetailsFromFlags(cmd *cli.Command) (*sumup.PersonalDetails, int, error) { + details := &sumup.PersonalDetails{} + changedCount := 0 + + if value := cmd.String("first-name"); value != "" { + details.FirstName = &value + changedCount++ + } + if value := cmd.String("last-name"); value != "" { + details.LastName = &value + changedCount++ + } + if value := cmd.String("email"); value != "" { + details.Email = &value + changedCount++ + } + if value := cmd.String("phone"); value != "" { + details.Phone = &value + changedCount++ + } + if value := cmd.String("tax-id"); value != "" { + details.TaxID = &value + changedCount++ + } + if value := cmd.String("birth-date"); value != "" { + parsedDate, err := time.Parse(time.DateOnly, value) + if err != nil { + return nil, 0, fmt.Errorf("invalid birth date %q: %w", value, err) + } + date := datetime.Date{Time: parsedDate} + details.BirthDate = &date + changedCount++ + } + + if changedCount == 0 { + return nil, 0, nil + } + return details, changedCount, nil +} + +func enumString[T ~string](value *T) *string { + if value == nil { + return nil + } + text := string(*value) + return &text +} diff --git a/internal/commands/customers/customers.go b/internal/commands/customers/customers.go index fc54fe6..af21381 100644 --- a/internal/commands/customers/customers.go +++ b/internal/commands/customers/customers.go @@ -42,6 +42,24 @@ func NewCommand() *cli.Command { ArgsUsage: "", Flags: customerDetailsFlags(), }, + { + Name: "payment-instruments", + Usage: "Manage stored payment instruments for a customer.", + Commands: []*cli.Command{ + { + Name: "list", + Usage: "List stored payment instruments for a customer.", + Action: listPaymentInstruments, + ArgsUsage: "", + }, + { + Name: "deactivate", + Usage: "Deactivate a stored payment instrument.", + Action: deactivatePaymentInstrument, + ArgsUsage: " ", + }, + }, + }, }, } } @@ -187,6 +205,67 @@ func updateCustomer(ctx context.Context, cmd *cli.Command) error { return nil } +func listPaymentInstruments(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + if cmd.Args().Len() != 1 { + return fmt.Errorf("expected exactly 1 argument: customer ID") + } + + customerID := cmd.Args().Get(0) + instruments, err := appCtx.Client.Customers.ListPaymentInstruments(ctx, customerID) + if err != nil { + return fmt.Errorf("list payment instruments: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(instruments) + } + + rows := make([][]attribute.Value, 0, len(*instruments)) + for _, instrument := range *instruments { + rows = append(rows, []attribute.Value{ + attribute.OptionalStringValue(instrument.Token), + attribute.OptionalValue(instrument.Type), + attribute.ValueOf(paymentInstrumentCardLabel(instrument.Card)), + attribute.ValueOf(util.BoolLabel(instrument.Active)), + attribute.ValueOf(util.TimeOrDash(appCtx, instrument.CreatedAt)), + }) + } + + display.RenderTable( + "Payment Instruments", + []string{"Token", "Type", "Card", "Active", "Created At"}, + rows, + ) + return nil +} + +func deactivatePaymentInstrument(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + if cmd.Args().Len() != 2 { + return fmt.Errorf("expected exactly 2 arguments: customer ID and token") + } + + customerID := cmd.Args().Get(0) + token := cmd.Args().Get(1) + if err := appCtx.Client.Customers.DeactivatePaymentInstrument(ctx, customerID, token); err != nil { + return fmt.Errorf("deactivate payment instrument: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(map[string]string{"status": "deactivated"}) + } + + message.Success("Payment instrument deactivated") + return nil +} + func customerDetailsFromFlags(cmd *cli.Command) (*sumup.PersonalDetails, int, error) { details := &sumup.PersonalDetails{} changedCount := 0 @@ -330,3 +409,21 @@ func formatAddress(address *sumup.AddressLegacy) string { } return strings.Join(parts, ", ") } + +func paymentInstrumentCardLabel(card *sumup.PaymentInstrumentResponseCard) string { + if card == nil { + return "-" + } + + parts := make([]string, 0, 2) + if card.Type != nil && *card.Type != "" { + parts = append(parts, string(*card.Type)) + } + if card.Last4Digits != nil && *card.Last4Digits != "" { + parts = append(parts, fmt.Sprintf("(****%s)", *card.Last4Digits)) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, " ") +} diff --git a/internal/commands/readers/readers.go b/internal/commands/readers/readers.go index 8f5b186..1a9a320 100644 --- a/internal/commands/readers/readers.go +++ b/internal/commands/readers/readers.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "strings" + "time" "github.com/urfave/cli/v3" @@ -89,6 +90,57 @@ func NewCommand() *cli.Command { }, }, }, + { + Name: "get", + Usage: "Get a paired reader.", + Action: getReader, + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "merchant-code", + Usage: "Merchant code that owns the reader.", + Sources: cli.EnvVars("SUMUP_MERCHANT_CODE"), + Required: true, + }, + &cli.StringFlag{ + Name: "if-modified-since", + Usage: "Optional If-Modified-Since query value.", + }, + }, + }, + { + Name: "update", + Usage: "Update a paired reader.", + Action: updateReader, + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "merchant-code", + Usage: "Merchant code that owns the reader.", + Sources: cli.EnvVars("SUMUP_MERCHANT_CODE"), + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Usage: "Updated reader name.", + Required: true, + }, + }, + }, + { + Name: "terminate", + Usage: "Terminate the current reader checkout.", + Action: terminateCheckout, + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "merchant-code", + Usage: "Merchant code that owns the reader.", + Sources: cli.EnvVars("SUMUP_MERCHANT_CODE"), + Required: true, + }, + }, + }, { Name: "checkout", Usage: "Trigger a checkout on a specific reader device.", @@ -371,6 +423,98 @@ func readerStatus(ctx context.Context, cmd *cli.Command) error { return nil } +func getReader(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + readerID, err := util.RequireSingleArg(cmd, "reader ID") + if err != nil { + return err + } + + params := sumup.ReadersGetParams{} + if value := cmd.String("if-modified-since"); value != "" { + params.IfModifiedSince = &value + } + + reader, err := appCtx.Client.Readers.Get(ctx, cmd.String("merchant-code"), sumup.ReaderID(readerID), params) + if err != nil { + return fmt.Errorf("get reader: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(reader) + } + + renderReader(reader) + return nil +} + +func updateReader(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + readerID, err := util.RequireSingleArg(cmd, "reader ID") + if err != nil { + return err + } + + name := sumup.ReaderName(cmd.String("name")) + body := sumup.ReadersUpdateParams{Name: &name} + reader, err := appCtx.Client.Readers.Update(ctx, cmd.String("merchant-code"), sumup.ReaderID(readerID), body) + if err != nil { + return fmt.Errorf("update reader: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(reader) + } + + message.Success("Reader updated") + renderReader(reader) + return nil +} + +func terminateCheckout(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + readerID, err := util.RequireSingleArg(cmd, "reader ID") + if err != nil { + return err + } + + if err := appCtx.Client.Readers.TerminateCheckout(ctx, cmd.String("merchant-code"), readerID); err != nil { + return fmt.Errorf("terminate reader checkout: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(map[string]string{"status": "termination_requested"}) + } + + message.Success("Reader checkout termination requested") + return nil +} + +func renderReader(reader *sumup.Reader) { + if reader == nil { + return + } + + display.DataList([]attribute.KeyValue{ + attribute.ID(string(reader.ID)), + attribute.Attribute("Name", attribute.Styled(string(reader.Name))), + attribute.Attribute("Status", attribute.Styled(string(reader.Status))), + attribute.Attribute("Model", attribute.Styled(string(reader.Device.Model))), + attribute.Attribute("Identifier", attribute.Styled(reader.Device.Identifier)), + attribute.Attribute("Updated At", attribute.Styled(reader.UpdatedAt.UTC().Format(time.RFC3339))), + attribute.OptionalString("Service Account ID", reader.ServiceAccountID), + }) +} + func readerStatusBatteryLevel(v *float32) string { if v == nil { return "-" diff --git a/internal/commands/transactions/transactions.go b/internal/commands/transactions/transactions.go index dfd0b93..9a11a6c 100644 --- a/internal/commands/transactions/transactions.go +++ b/internal/commands/transactions/transactions.go @@ -84,15 +84,43 @@ func NewCommand() *cli.Command { }, { Name: "get", - Usage: "Get a specific transaction by ID.", + Usage: "Get a specific transaction.", Action: getTransaction, - ArgsUsage: "", + ArgsUsage: "[transaction-id]", Flags: []cli.Flag{ &cli.StringFlag{ Name: "merchant-code", Usage: "Merchant code that owns the transaction. Falls back to context.", Sources: cli.EnvVars("SUMUP_MERCHANT_CODE"), }, + &cli.StringFlag{ + Name: "internal-id", + Usage: "Lookup by internal transaction ID.", + }, + &cli.StringFlag{ + Name: "transaction-code", + Usage: "Lookup by transaction code.", + }, + &cli.StringFlag{ + Name: "foreign-transaction-id", + Usage: "Lookup by foreign transaction ID.", + }, + &cli.StringFlag{ + Name: "client-transaction-id", + Usage: "Lookup by client transaction ID.", + }, + }, + }, + { + Name: "refund", + Usage: "Refund a transaction fully or partially.", + Action: refundTransaction, + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.Float64Flag{ + Name: "amount", + Usage: "Optional partial refund amount in major units.", + }, }, }, }, @@ -213,12 +241,34 @@ func getTransaction(ctx context.Context, cmd *cli.Command) error { return err } - transactionID, err := util.RequireSingleArg(cmd, "transaction ID") - if err != nil { - return err + params := sumup.TransactionsGetParams{} + lookupCount := 0 + if cmd.Args().Len() > 0 { + transactionID := cmd.Args().Get(0) + params.ID = &transactionID + lookupCount++ + } + if value := cmd.String("internal-id"); value != "" { + params.InternalID = &value + lookupCount++ + } + if value := cmd.String("transaction-code"); value != "" { + params.TransactionCode = &value + lookupCount++ + } + if value := cmd.String("foreign-transaction-id"); value != "" { + params.ForeignTransactionID = &value + lookupCount++ } - params := sumup.TransactionsGetParams{ - ID: &transactionID, + if value := cmd.String("client-transaction-id"); value != "" { + params.ClientTransactionID = &value + lookupCount++ + } + if lookupCount == 0 { + return fmt.Errorf("provide a transaction ID argument or one lookup flag") + } + if lookupCount > 1 { + return fmt.Errorf("provide exactly one transaction lookup") } transaction, err := appCtx.Client.Transactions.Get(ctx, merchantCode, params) @@ -234,6 +284,33 @@ func getTransaction(ctx context.Context, cmd *cli.Command) error { return nil } +func refundTransaction(ctx context.Context, cmd *cli.Command) error { + appCtx, err := app.GetAppContext(cmd) + if err != nil { + return err + } + transactionID, err := util.RequireSingleArg(cmd, "transaction ID") + if err != nil { + return err + } + + body := sumup.TransactionsRefundParams{} + if cmd.IsSet("amount") { + value := float32(cmd.Float64("amount")) + body.Amount = &value + } + + if err := appCtx.Client.Transactions.Refund(ctx, transactionID, body); err != nil { + return fmt.Errorf("refund transaction: %w", err) + } + + if appCtx.JSONOutput { + return display.PrintJSON(map[string]string{"status": "refunded"}) + } + + return nil +} + func renderTransactionDetails(appCtx *app.Context, transaction *sumup.TransactionFull) { status := "-" if transaction.Status != nil && *transaction.Status != "" {