Skip to content

Commit 67edf79

Browse files
authored
Merge pull request #6130 from woocommerce/issue/5976-show-last4-and-card-brand-on-refund-confirmation
[Mobile Payments] Show card's brand and last 4 digits on refunds
2 parents 0539631 + f6d6b0c commit 67edf79

File tree

6 files changed

+121
-3
lines changed

6 files changed

+121
-3
lines changed

Networking/Networking/Remote/OrdersRemote.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,13 @@ public extension OrdersRemote {
274274
private static let commonOrderFieldValues = [
275275
"id", "parent_id", "number", "status", "currency", "customer_id", "customer_note", "date_created_gmt", "date_modified_gmt", "date_paid_gmt",
276276
"discount_total", "discount_tax", "shipping_total", "shipping_tax", "total", "total_tax", "payment_method", "payment_method_title",
277-
"billing", "coupon_lines", "shipping_lines", "refunds", "fee_lines", "order_key", "tax_lines"
277+
"billing", "coupon_lines", "shipping_lines", "refunds", "fee_lines", "order_key", "tax_lines", "meta_data"
278278
]
279+
// Use with caution. Any fields in here will be overwritten with empty values by
280+
// `Order+ReadOnlyConvertible.swift: Order.update(with:)` when the list of orders is fetched.
281+
// See p91TBi-7yL-p2 for discussion.
279282
private static let singleOrderExtraFieldValues = [
280-
"line_items", "shipping", "meta_data"
283+
"line_items", "shipping"
281284
]
282285
}
283286

WooCommerce/Classes/ViewRelated/Orders/Order Details/Issue Refunds/IssueRefundViewModel.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ final class IssueRefundViewModel {
9494
return resultsController.fetchedObjects.first
9595
}()
9696

97+
/// Charge related to the order. Used to show card details in the `Refund Via` section.
98+
///
99+
private lazy var charge: WCPayCharge? = {
100+
guard let resultsController = createWcPayChargeResultsController() else {
101+
return nil
102+
}
103+
try? resultsController.performFetch()
104+
return resultsController.fetchedObjects.first
105+
}()
106+
97107
private let analytics: Analytics
98108

99109
init(order: Order, refunds: [Refund], currencySettings: CurrencySettings, analytics: Analytics = ServiceLocator.analytics) {
@@ -106,12 +116,14 @@ final class IssueRefundViewModel {
106116
isSelectAllButtonVisible = calculateSelectAllButtonVisibility()
107117
selectedItemsTitle = createSelectedItemsCount()
108118
hasUnsavedChanges = calculatePendingChangesState()
119+
fetchCharge()
109120
}
110121

111122
/// Creates the `ViewModel` to be used when navigating to the page where the user can
112123
/// confirm and submit the refund.
113124
func createRefundConfirmationViewModel() -> RefundConfirmationViewModel {
114125
let details = RefundConfirmationViewModel.Details(order: state.order,
126+
charge: charge,
115127
amount: "\(calculateRefundTotal())",
116128
refundsShipping: state.shouldRefundShipping,
117129
refundsFees: state.shouldRefundFees,
@@ -220,6 +232,22 @@ private extension IssueRefundViewModel {
220232
let predicate = NSPredicate(format: "siteID == %lld AND gatewayID == %@", state.order.siteID, state.order.paymentMethodID)
221233
return ResultsController<StoragePaymentGateway>(storageManager: ServiceLocator.storageManager, matching: predicate, sortedBy: [])
222234
}
235+
236+
func createWcPayChargeResultsController() -> ResultsController<StorageWCPayCharge>? {
237+
guard let chargeID = state.order.chargeID else {
238+
return nil
239+
}
240+
let predicate = NSPredicate(format: "siteID == %ld AND chargeID == %@", state.order.siteID, chargeID)
241+
return ResultsController<StorageWCPayCharge>(storageManager: ServiceLocator.storageManager, matching: predicate, sortedBy: [])
242+
}
243+
244+
func fetchCharge() {
245+
guard let chargeID = state.order.chargeID else {
246+
return
247+
}
248+
let action = CardPresentPaymentAction.fetchWCPayCharge(siteID: state.order.siteID, chargeID: chargeID, onCompletion: { _ in })
249+
ServiceLocator.stores.dispatch(action)
250+
}
223251
}
224252

225253
// MARK: Constants

WooCommerce/Classes/ViewRelated/Orders/Order Details/Issue Refunds/RefundConfirmationViewModel.swift

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ extension RefundConfirmationViewModel {
115115
///
116116
let order: Order
117117

118+
/// Charge of original payment
119+
///
120+
let charge: WCPayCharge?
121+
118122
/// Total amount to refund
119123
///
120124
let amount: String
@@ -151,7 +155,11 @@ private extension RefundConfirmationViewModel {
151155
///
152156
func makeRefundViaRow() -> RefundConfirmationViewModelRow {
153157
if gatewaySupportsAutomaticRefunds() {
154-
return SimpleTextRow(text: details.order.paymentMethodTitle)
158+
guard case .cardPresent(let cardDetails) = details.charge?.paymentMethodDetails else {
159+
return SimpleTextRow(text: details.order.paymentMethodTitle)
160+
}
161+
return TitleAndBodyRow(title: details.order.paymentMethodTitle,
162+
body: cardDetails.brand.cardDescription(last4: cardDetails.last4))
155163
} else {
156164
return TitleAndBodyRow(title: Localization.manualRefund(via: details.order.paymentMethodTitle),
157165
body: Localization.refundWillNotBeIssued(paymentMethod: details.order.paymentMethodTitle))
@@ -279,3 +287,39 @@ private extension RefundConfirmationViewModel {
279287
}
280288
}
281289
}
290+
291+
private extension WCPayCardBrand {
292+
/// A displayable brand name and last 4 digits for a card. These are deliberately not localized, always in English,
293+
/// because of various limitations on localization by the card companies. Care should be taken if localizing (some of)
294+
/// these brand names in future – e.g. Mastercard allows only English, or specific authorized versions in Chinese (translation),
295+
/// Arabic (transliteration), and Georgian (transliteration).
296+
///
297+
/// Names taken from [Stripe's card branding in the API docs](https://stripe.com/docs/api/cards/object#card_object-brand):
298+
/// American Express, Diners Club, Discover, JCB, Mastercard, UnionPay, Visa, or Unknown.
299+
/// N.B. on review, we found that Mastercard should not have an uppercase "c" as it does in Stripe's documentation
300+
/// https://brand.mastercard.com/brandcenter/branding-requirements/mastercard.html#name
301+
func cardDescription(last4: String) -> String {
302+
return String(format: cardDescriptionFormatString(), last4)
303+
}
304+
305+
func cardDescriptionFormatString() -> String {
306+
switch self {
307+
case .amex:
308+
return "•••• %1$@ (American Express)"
309+
case .diners:
310+
return "•••• %1$@ (Diners Club)"
311+
case .discover:
312+
return "•••• %1$@ (Discover)"
313+
case .jcb:
314+
return "•••• %1$@ (JCB)"
315+
case .mastercard:
316+
return "•••• %1$@ (Mastercard)"
317+
case .unionpay:
318+
return "•••• %1$@ (UnionPay)"
319+
case .visa:
320+
return "•••• %1$@ (Visa)"
321+
case .unknown:
322+
return "•••• %1$@"
323+
}
324+
}
325+
}

WooCommerce/WooCommerceTests/ViewRelated/Orders/Issue Refund/RefundConfirmationViewModelTests.swift

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
3838
let order = MockOrders().empty().copy(refunds: refundItems)
3939

4040
let details = RefundConfirmationViewModel.Details(order: order,
41+
charge: nil,
4142
amount: "0.0",
4243
refundsShipping: false,
4344
refundsFees: false,
@@ -64,6 +65,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
6465

6566
let order = MockOrders().empty()
6667
let details = RefundConfirmationViewModel.Details(order: order,
68+
charge: nil,
6769
amount: "130.3473",
6870
refundsShipping: false,
6971
refundsFees: false,
@@ -82,6 +84,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
8284
let order = MockOrders().empty().copy(paymentMethodID: "stipe", paymentMethodTitle: "Stripe")
8385
let gateway = PaymentGateway(siteID: 123, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [.refunds])
8486
let details = RefundConfirmationViewModel.Details(order: order,
87+
charge: nil,
8588
amount: "",
8689
refundsShipping: false,
8790
refundsFees: false,
@@ -98,11 +101,39 @@ final class RefundConfirmationViewModelTests: XCTestCase {
98101
XCTAssertEqual(row.text, order.paymentMethodTitle)
99102
}
100103

104+
func test_viewModel_includes_card_details_in_refundVia_values_when_charge_is_available() throws {
105+
// Given
106+
let order = MockOrders().empty().copy(paymentMethodID: "stipe", paymentMethodTitle: "Stripe")
107+
let paymentMethodDetails = WCPayCardPresentPaymentDetails(brand: .mastercard, last4: "6292", funding: .credit, receipt: .fake())
108+
let charge = WCPayCharge.fake().copy(paymentMethodDetails: .cardPresent(details: paymentMethodDetails))
109+
let gateway = PaymentGateway(siteID: 123, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [.refunds])
110+
let details = RefundConfirmationViewModel.Details(order: order,
111+
charge: charge,
112+
amount: "",
113+
refundsShipping: false,
114+
refundsFees: false,
115+
items: [],
116+
paymentGateway: gateway)
117+
118+
// When
119+
let viewModel = RefundConfirmationViewModel(details: details)
120+
121+
// We expect the Refund Via row to be the last item in the last row.
122+
let row = try XCTUnwrap(viewModel.sections.last?.rows.last as? RefundConfirmationViewModel.TitleAndBodyRow)
123+
124+
// Then
125+
XCTAssertEqual(row.title, order.paymentMethodTitle)
126+
let body = try XCTUnwrap(row.body)
127+
XCTAssert(body.contains("Mastercard"))
128+
XCTAssert(body.contains("6292"))
129+
}
130+
101131
func test_viewModel_has_manual_refundVia_values_when_using_a_gateway_that_does_not_support_refunds() throws {
102132
// Given
103133
let order = MockOrders().empty().copy(paymentMethodID: "stipe", paymentMethodTitle: "Stripe")
104134
let gateway = PaymentGateway(siteID: 123, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [])
105135
let details = RefundConfirmationViewModel.Details(order: order,
136+
charge: nil,
106137
amount: "",
107138
refundsShipping: false,
108139
refundsFees: false,
@@ -126,6 +157,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
126157
// Given
127158
let order = MockOrders().empty()
128159
let details = RefundConfirmationViewModel.Details(order: order,
160+
charge: nil,
129161
amount: "100.0",
130162
refundsShipping: false,
131163
refundsFees: false,
@@ -162,6 +194,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
162194
// Given
163195
let order = MockOrders().empty()
164196
let details = RefundConfirmationViewModel.Details(order: order,
197+
charge: nil,
165198
amount: "100.0",
166199
refundsShipping: false,
167200
refundsFees: false,
@@ -201,6 +234,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
201234
let order = MockOrders().empty().copy(paymentMethodID: "stipe", paymentMethodTitle: "Stripe")
202235
let gateway = PaymentGateway(siteID: 123, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [.refunds])
203236
let details = RefundConfirmationViewModel.Details(order: order,
237+
charge: nil,
204238
amount: "100.0",
205239
refundsShipping: true,
206240
refundsFees: true,
@@ -230,6 +264,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
230264
let order = MockOrders().empty().copy(paymentMethodID: "stipe", paymentMethodTitle: "Stripe")
231265
let gateway = PaymentGateway(siteID: 123, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [.products])
232266
let details = RefundConfirmationViewModel.Details(order: order,
267+
charge: nil,
233268
amount: "100.0",
234269
refundsShipping: true,
235270
refundsFees: true,
@@ -257,6 +292,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
257292
// Given
258293
let order = MockOrders().empty()
259294
let details = RefundConfirmationViewModel.Details(order: order,
295+
charge: nil,
260296
amount: "100.0",
261297
refundsShipping: false,
262298
refundsFees: false,
@@ -295,6 +331,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
295331
// Given
296332
let order = MockOrders().makeOrder()
297333
let details = RefundConfirmationViewModel.Details(order: order,
334+
charge: nil,
298335
amount: "0.0",
299336
refundsShipping: false,
300337
refundsFees: false,
@@ -315,6 +352,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
315352
let order = MockOrders().empty().copy(orderID: 123, total: "100.0", paymentMethodID: "stripe")
316353
let gateway = PaymentGateway(siteID: 234, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [])
317354
let details = RefundConfirmationViewModel.Details(order: order,
355+
charge: nil,
318356
amount: "100.0",
319357
refundsShipping: false,
320358
refundsFees: false,
@@ -339,6 +377,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
339377
let order = MockOrders().empty().copy(orderID: 123, total: "120.0", paymentMethodID: "stripe")
340378
let gateway = PaymentGateway(siteID: 234, gatewayID: "stripe", title: "Stripe", description: "", enabled: true, features: [])
341379
let details = RefundConfirmationViewModel.Details(order: order,
380+
charge: nil,
342381
amount: "100.0",
343382
refundsShipping: false,
344383
refundsFees: false,
@@ -362,6 +401,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
362401
// Given
363402
let order = MockOrders().empty()
364403
let details = RefundConfirmationViewModel.Details(order: order,
404+
charge: nil,
365405
amount: "100.0",
366406
refundsShipping: false,
367407
refundsFees: false,
@@ -396,6 +436,7 @@ final class RefundConfirmationViewModelTests: XCTestCase {
396436
// Given
397437
let order = MockOrders().empty()
398438
let details = RefundConfirmationViewModel.Details(order: order,
439+
charge: nil,
399440
amount: "100.0",
400441
refundsShipping: false,
401442
refundsFees: false,

Yosemite/Yosemite/Model/Model.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ public typealias StorageSystemPlugin = Storage.SystemPlugin
215215
public typealias StorageTopEarnerStats = Storage.TopEarnerStats
216216
public typealias StorageTopEarnerStatsItem = Storage.TopEarnerStatsItem
217217
public typealias StorageTaxClass = Storage.TaxClass
218+
public typealias StorageWCPayCharge = Storage.WCPayCharge
218219

219220
// MARK: - Internal ReadOnly Models
220221

Yosemite/Yosemite/Model/Storage/Order+ReadOnlyConvertible.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ extension Storage.Order: ReadOnlyConvertible {
3232
totalTax = order.totalTax
3333
paymentMethodID = order.paymentMethodID
3434
paymentMethodTitle = order.paymentMethodTitle
35+
chargeID = order.chargeID
3536

3637
if let billingAddress = order.billingAddress {
3738
billingFirstName = billingAddress.firstName

0 commit comments

Comments
 (0)