Skip to content

36-feat-card-transactions #48

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

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
Draft
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,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to this you are expecting to have 3 columns to be filled-in in the CSV file. Is that correct? I finally understand the purpose of these changes and have a many doubts if this even should be here.
The original idea of the transactions.csv file is to contain transactions related to the depot. Card transactions will simply pollute the data in the csv file. I am ready to accept it if we will put this functionality under some argument that can be passed to enable it. Otherwise I would not like to have all card transactions in the same file where I track my depot changes.
If you agree to put this behind an argument we can do it either by me adding support of custom arguments that can be passed to enable "extra functionality" or you adding it yourself.
Or we can have a separate file for that which would require more changes in the code but in the end sounds like a cleaner solution. Plus, we don't have to normalize all card transactions to asset transactions, since card transactions don't have fields such as "instrument", "ISIN", etc.

Let me know which approach we shall take and I can support you with coding if needed. But again, adding all card transactions among asset transactions sounds like a bad idea to me.

Copy link
Author

@parthux1 parthux1 Jan 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't worry about any delays. I'm currently not in a hurry to implement this feature (i'm going to do it, but at some days I'm just not in the mood 😓)

the following columns of the CSV are currently filled:

field value
ID generated as in other rows
Status generated as in other rows
Timestamp date of transaction
Type Cash
Name Beneficiary (name)
Debit amount payed

ID, Debit, Type are currently set in the class instantiation. Timestamp is set in the init-call in ln. 145
Status and Name are in fact missing 😓

Let's write card transaction related to the credit card in a separate CSV only containing the designated fields.
maybe a lot of the code will work out of the box if we update the writer to accept a CSVEntryInterface where the implementations contain different fields?

Adding fields as an dynamic field by using e.g. a map would probably be easier, but you'd loose easy strong typing.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I finally found some time to work on this :)
Let me check what we can do to write card transactions into a separate csv file. I will report back shortly.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I checked it. It requires some changes. I need to rewrite the way data is being written into a CSV file. It is currently written in a way that it is not easy to write into another csv file using different structure. I will create a branch in my repo and a PR and will keep you posted about the progress. Once we merge my changes we can adapt yours to utilize writing into another csv file using a structure that is more suitable for card transactions.

},
}

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)
}