Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

36-feat-card-transactions #48

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions internal/traderepublc/api/timeline/details/normalizer.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ func (n TransactionResponseNormalizer) Normalize(response Response) (NormalizedR
case SectionTitleTransaction, SectionTitleTransactionAlt:
resp.Transaction = NormalizedResponseTransactionSection{tableSection}
case SectionTitleSavingPlan:
// generated by CardTransactionPayment but irrelevant
case CardPaymentTransactionHelpSection, CardPaymentTransactionGainedBenefits:
default:
n.logger.Warnf("unknown section title: %v", tableSection.Title)
}
Expand Down
3 changes: 3 additions & 0 deletions internal/traderepublc/api/timeline/details/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const (
OrderTypeTextsSale = "Verkauf"
OrderTypeTextsPurchase = "Kauf"
TrendNegative = "negative"
CardPaymentTransactionBeneficiary = "Händler"
CardPaymentTransactionHelpSection = "Hilfe"
CardPaymentTransactionGainedBenefits = "Vorteile"
)

var ErrSectionDataTitleNotFound = errors.New("section data title not found")
Expand Down
6 changes: 6 additions & 0 deletions internal/traderepublc/api/timeline/details/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ func NewTypeResolver(logger *log.Logger) TypeResolver {
TypeRoundUpTransaction: RoundUpDetector,
TypeSavebackTransaction: SavebackDetector,
TypeInterestPayoutTransaction: InterestPayoutDetector,
TypeCardPaymentTransaction: CardPaymentDetector,

// Detectors with the highest performance hit should be listed in the bottom.
TypePurchaseTransaction: PurchaseDetector,
Expand Down Expand Up @@ -131,3 +132,8 @@ func DividendPayoutDetector(eventType transactions.EventType, _ NormalizedRespon
func WithdrawalDetector(eventType transactions.EventType, _ NormalizedResponse) bool {
return eventType == transactions.EventTypePaymentOutbound
}

func CardPaymentDetector(eventType transactions.EventType, response NormalizedResponse) bool {
// TODO: incomplete. What about failed transactions / refunds?
return eventType == transactions.EventTypeCardSuccessfulTransaction
}
1 change: 1 addition & 0 deletions internal/traderepublc/api/timeline/transactions/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ func NewEventTypeResolver(logger *log.Logger) EventTypeResolver {
EventTypeBenefitsSavebackExecution,
EventTypeBenefitsSpareChangeExecution,
EventTypeSSPCorporateActionInvoiceCash,
EventTypeCardSuccessfulTransaction,
},
logger: logger,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func (b ModelBuilder) ExtractInstrumentName(response details.NormalizedResponse)
details.OverviewDataTitleAsset,
details.OverviewDataTitleUnderlyingAsset,
details.OverviewDataTitleSecurity,
details.CardPaymentTransactionBeneficiary,
)
if err != nil {
return "", fmt.Errorf("could not get overview section asset: %w", err)
Expand Down
2 changes: 2 additions & 0 deletions internal/traderepublc/portfolio/transaction/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ func (f CSVEntryFactory) Make(transaction Model) (filesystem.CSVEntry, error) {
case TypeDividendPayout:
profit = transaction.Total
credit = transaction.Total
case TypeCardPaymentTransaction:
debit = transaction.Total
default:
return filesystem.CSVEntry{}, fmt.Errorf(
"unsupported type '%s' received: %w",
Expand Down
17 changes: 9 additions & 8 deletions internal/traderepublc/portfolio/transaction/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ import (
)

const (
TypePurchase = "Purchase"
TypeSale = "Sale"
TypeDividendPayout = "Dividends"
TypeRoundUp = "Round up"
TypeSaveback = "Saveback"
TypeDeposit = "Deposit"
TypeWithdrawal = "Withdrawal"
TypeInterestPayout = "Interest payout"
TypePurchase = "Purchase"
TypeSale = "Sale"
TypeDividendPayout = "Dividends"
TypeRoundUp = "Round up"
TypeSaveback = "Saveback"
TypeDeposit = "Deposit"
TypeWithdrawal = "Withdrawal"
TypeInterestPayout = "Interest payout"
TypeCardPaymentTransaction = "Card payment"
)

type Model struct {
Expand Down
67 changes: 64 additions & 3 deletions internal/traderepublc/portfolio/transaction/model_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,9 @@ func (f ModelBuilderFactory) Create(
return NewWithdrawBuilder(NewDepositBuilder(baseBuilder)), nil
case details.TypeInterestPayoutTransaction:
return NewInterestPayoutBuilder(baseBuilder), nil
case
details.TypeUnsupported,
details.TypeCardPaymentTransaction:
case details.TypeCardPaymentTransaction:
return NewPaymentTransactionBuilder(baseBuilder), nil
case details.TypeUnsupported:
return nil, ErrModelBuilderUnsupportedType
}

Expand Down Expand Up @@ -523,3 +523,64 @@ func (b InterestPayoutBuilder) Build() (Model, error) {

return model, nil
}

type PaymentTransactionBuilder struct {
BaseModelBuilder
}

func NewPaymentTransactionBuilder(baseBuilder BaseModelBuilder) PaymentTransactionBuilder {
return PaymentTransactionBuilder{BaseModelBuilder: baseBuilder}
}

func (b PaymentTransactionBuilder) ExtractTotalAmount() (float64, error) {
totalAmountStr, err := ParseNumericValueFromString(b.response.Header.Title)
if err != nil {
return 0, err
}

total, err := ParseFloatWithComma(totalAmountStr, false)
if err != nil {
return total, err
}

return total, nil
}

func (b PaymentTransactionBuilder) Build() (Model, error) {
var err error

// data is mapped as follows:
// paid amount: -> model.Total
// beneficiary: -> model.Instrument.Name

model := Model{
UUID: b.response.ID,
Type: TypeCardPaymentTransaction,
}

model.Total, err = b.ExtractTotalAmount()
if err != nil {
return model, b.HandleErr(err)
}

model.Instrument, err = b.instrumentBuilder.Build(b.response)
if err != nil {
return model, b.HandleErr(err)
}

// TypeResolver executed in instance of instrumentBuilder can't detect if an isntrument name refers to a beneficiary.
// Thus it maps card payments to Other. We manually overwrite this here.
model.Instrument.Type = instrument.TypeCash

model.Status, err = b.ExtractStatus()
if err != nil {
return model, b.HandleErr(err)
}

model.Timestamp, err = b.ExtractTimestamp()
if err != nil {
return model, b.HandleErr(err)
}

return model, nil
}
170 changes: 130 additions & 40 deletions tests/fakes/card_successful_transaction_01.go
Original file line number Diff line number Diff line change
@@ -1,57 +1,147 @@
package fakes

import "github.com/dhojayev/traderepublic-portfolio-downloader/internal/traderepublc/api/timeline/transactions"
import (
"time"

"github.com/dhojayev/traderepublic-portfolio-downloader/internal"
"github.com/dhojayev/traderepublic-portfolio-downloader/internal/filesystem"
"github.com/dhojayev/traderepublic-portfolio-downloader/internal/traderepublc/api/timeline/details"
"github.com/dhojayev/traderepublic-portfolio-downloader/internal/traderepublc/api/timeline/transactions"
"github.com/dhojayev/traderepublic-portfolio-downloader/internal/traderepublc/portfolio/instrument"
"github.com/dhojayev/traderepublic-portfolio-downloader/internal/traderepublc/portfolio/transaction"
)

var CardSuccessfulTransaction01 = TransactionTestCase{
TimelineTransactionsData: TimelineTransactionsTestData{
Raw: []byte(`[
{
}
]`)},
TimelineDetailsData: TimelineDetailsTestData{
Raw: []byte(`{
"items":
[
{
"action": {
"payload": "6221f5fb-b8fa-4ad6-8c99-c3fb3c31da10",
"type": "timelineDetail"
"id": "f729b13a-ed08-5e48-bdde-87f17c478e48",
"sections": [
{
"data": {
"icon": "merchant-logos/a95de37b-f62d-42fe-b226-0d3bcae8df0c",
"status": "executed",
"timestamp": "2024-04-07T18:03:33.954+0000"
},
"title": "Du hast 2,00 € ausgegeben",
"type": "header"
},
{
"data": [
{
"detail": {
"functionalStyle": "EXECUTED",
"text": "Fertig",
"type": "status"
},
"style": "plain",
"title": "Status"
},
"amount": {
"currency": "EUR",
"fractionDigits": 2,
"value": -5.95
{
"detail": {
"icon": "logos/bank_traderepublic/v2",
"text": "··1234",
"type": "iconWithText"
},
"style": "plain",
"title": "Zahlung"
},
"badge": null,
"eventType": "card_successful_transaction",
"icon": "logos/merchant-45180dc7-8917-45c9-b926-6ae7b3befe28/v2",
"id": "6221f5fb-b8fa-4ad6-8c99-c3fb3c31da10",
"status": "EXECUTED",
"subAmount": null,
"subtitle": null,
"timestamp": "2024-05-27T13:51:55.167+0000",
"title": "Aldi"
}
]
}`),
Unmarshalled: transactions.ResponseItem{
Action: transactions.ResponseItemAction{
Payload: "6221f5fb-b8fa-4ad6-8c99-c3fb3c31da10",
Type: "timelineDetail",
{
"detail": {
"text": "Stayery",
"type": "text"
},
"style": "plain",
"title": "Händler"
}
],
"title": "Übersicht",
"type": "table"
},
Amount: transactions.ResponseItemAmount{
Currency: "EUR",
FractionDigits: 2,
Value: -5.95,
{
"data": [
{
"detail": {
"action": {
"type": "benefitsSavebackOverview"
},
"amount": "0,02 €",
"icon": "logos/IE0031442068/v2",
"status": "executed",
"subtitle": "Saveback",
"timestamp": "2024-04-07T18:03:34.802+0000",
"title": "Core S\u0026P 500 USD (Dist)",
"type": "embeddedTimelineItem"
},
"style": "plain",
"title": "Core S\u0026P 500 USD (Dist)"
}
],
"title": "Vorteile",
"type": "table"
},
{
"data": [
{
"detail": {
"action": {
"payload": {
"contextCategory": "card-dispute",
"contextParams": {
"card-dispute-txId": "f729b13a-ed08-5e48-bdde-87f17c478e48"
},
"transactionId": "f729b13a-ed08-5e48-bdde-87f17c478e48"
},
"type": "customerSupportChat"
},
"icon": "logos/timeline_communication/v2",
"type": "listItemAvatarDefault"
},
"style": "highlighted",
"title": "Problem melden"
}
],
"title": "Hilfe",
"type": "table"
}
]
}`),
Normalized: details.NormalizedResponse{
ID: "f729b13a-ed08-5e48-bdde-87f17c478e48",
Header: details.NormalizedResponseHeaderSection{
Data: details.NormalizedResponseHeaderSectionData{
Icon: "merchant-logos/a95de37b-f62d-42fe-b226-0d3bcae8df0c",
Status: "executed",
Timestamp: "2024-04-07T18:03:33.954+0000",
},
Title: "Du hast 2,00 € ausgegeben",
Type: "header",
},
EventType: "card_successful_transaction",
Icon: "logos/merchant-45180dc7-8917-45c9-b926-6ae7b3befe28/v2",
ID: "6221f5fb-b8fa-4ad6-8c99-c3fb3c31da10",
Status: "EXECUTED",
Timestamp: "2024-05-27T13:51:55.167+0000",
Title: "Aldi",
},
},
TimelineDetailsData: TimelineDetailsTestData{
Raw: []byte("{}"),
EventType: transactions.EventTypeCardSuccessfulTransaction,
Transaction: transaction.Model{
UUID: "f729b13a-ed08-5e48-bdde-87f17c478e48",
Type: transaction.TypeCardPaymentTransaction,
Status: "executed",
Total: 2,
Instrument: instrument.Model{
Name: "Stayery",
},
},
CSVEntry: filesystem.CSVEntry{
ID: "f729b13a-ed08-5e48-bdde-87f17c478e48",
Debit: 2,
Type: transaction.TypeCardPaymentTransaction,
},
}

func init() {
RegisterUnsupported("CardSuccessfulTransaction01", CardSuccessfulTransaction01)
CardSuccessfulTransaction01.Transaction.Timestamp, _ = time.Parse(details.ResponseTimeFormat, "2024-04-07T18:03:33.954+0000")
CardSuccessfulTransaction01.CSVEntry.Timestamp = internal.DateTime{Time: CardSuccessfulTransaction01.Transaction.Timestamp}
RegisterSupported("CardSuccessfulTransaction01", CardSuccessfulTransaction01)
}