Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/fix-amazon-pay-fee-calculations
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fix

fix: Amazon Pay fee display on order details page and timeline view when the payment was processed with non-card instrument
1 change: 1 addition & 0 deletions client/data/timeline/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface TimelineFeeRates {
fee_exchange_rate?: TimelineFeeExchangeRate;
tax?: TimelineFeeTax;
before_tax?: TimelineFeeTax;
fee_refunded?: boolean;
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Server sets fee_rates.fee_refunded: true on refunded captured events.

}

export interface TimelineTransactionDetails {
Expand Down
36 changes: 31 additions & 5 deletions client/payment-details/timeline/map-events.js
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ const formatNetString = ( event ) => {
},
} = event;

if ( ! isFXEvent( event ) ) {
const isFeeRefunded = event.fee_rates?.fee_refunded;

if ( ! isFXEvent( event ) && ! isFeeRefunded ) {
return formatExplicitCurrency(
amountCaptured - fee,
currency,
Expand All @@ -291,7 +293,9 @@ const formatNetString = ( event ) => {
);
}

// We need to use the store amount and currency for the net amount calculation in the case of a FX event.
// For FX events and refunded-fee events, use store amounts.
// When the fee was refunded, store_fee holds only Stripe's processing
// fee while the event fee was zeroed out, so store values are correct.
return formatExplicitCurrency(
storeAmountCaptured - storeFee,
storeCurrency,
Expand Down Expand Up @@ -824,14 +828,36 @@ const mapEventToTimelineItems = ( event, bankName = null ) => {
];
case 'captured':
const formattedNet = formatNetString( event );
const isFeeRefunded = !! event.fee_rates?.fee_refunded;
const stripeProcessingFee =
event.fee_rates?.stripe_processing_fee || 0;
const stripeProcessingFeeCurrency =
event.fee_rates?.stripe_processing_fee_currency ||
event.transaction_details?.store_currency;
const body = [
composeFXString( event ),
composeFeeString( event ),
composeFeeBreakdown( event ),
isFeeRefunded ? null : composeFeeString( event ),
isFeeRefunded ? null : composeFeeBreakdown( event ),
event?.fee_rates?.tax?.amount !== 0
? composeTaxString( event )
: null,
composeNetString( event ),
// Server only populates stripe_processing_fee once the balance
// transaction has settled on a refunded-fee event.
stripeProcessingFee > 0
? sprintf(
/* translators: %s is a monetary amount */
__( 'Processing fee: -%s', 'woocommerce-payments' ),
formatExplicitCurrency(
stripeProcessingFee,
stripeProcessingFeeCurrency
)
)
: null,
// For refunded events, skip the net line until Stripe's fee is
// known — otherwise it would use stale (pre-settlement) values.
isFeeRefunded && stripeProcessingFee <= 0
? null
: composeNetString( event ),
].filter( Boolean );
return [
getStatusChangeTimelineItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ const FeesBreakdown: React.FC< {
return null;
}

if ( event.fee_rates.fee_refunded ) {
return null;
}
Comment on lines +21 to +23
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Server zeros percentage/fixed/history/before_tax/tax.
Without the guard, the component would render a nonsensical "0% + $0.00 = total" breakdown.

Image


const storeCurrency = event.transaction_details.store_currency;
const feeExchangeRate = event.fee_rates.fee_exchange_rate?.rate || 1;
const discountFee = event.fee_rates.history
Expand Down
51 changes: 38 additions & 13 deletions includes/class-wc-payments-captured-event-note.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,40 @@ public function generate_html_note(): string {
$lines[] = $fx_string;
}

$lines[] = $this->compose_fee_string();
$fee_rates = $this->captured_event['fee_rates'] ?? [];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Tweaking the order note to make sure it shows the correct fee

Image

$fee_refunded = ! empty( $fee_rates['fee_refunded'] );

if ( $fee_refunded ) {
// When the balance transaction has settled, Stripe's processing fee
// is available as store_fee. Show it and include the net payout.
// When store_fee is 0 the BT hasn't settled — skip both lines.
$store_fee = $this->captured_event['transaction_details']['store_fee'] ?? 0;
$store_currency = $this->captured_event['transaction_details']['store_currency'] ?? '';
if ( $store_fee > 0 ) {
$lines[] = sprintf(
/* translators: %s is a monetary amount */
__( 'Processing fee: -%s', 'woocommerce-payments' ),
WC_Payments_Utils::format_explicit_currency(
WC_Payments_Utils::interpret_stripe_amount( (int) $store_fee, $store_currency ),
$store_currency
)
);
$lines[] = $this->compose_net_string();
}
} else {
$lines[] = $this->compose_fee_string();

$fee_breakdown_lines = $this->compose_fee_break_down();
if ( null !== $fee_breakdown_lines ) {
$lines = array_merge( $lines, $fee_breakdown_lines );
}
$fee_breakdown_lines = $this->compose_fee_break_down();
if ( null !== $fee_breakdown_lines ) {
$lines = array_merge( $lines, $fee_breakdown_lines );
}

if ( $this->has_tax() ) {
$lines[] = $this->compose_tax_string();
}
if ( $this->has_tax() ) {
$lines[] = $this->compose_tax_string();
}

$lines[] = $this->compose_net_string();
$lines[] = $this->compose_net_string();
}

$html = '';
foreach ( $lines as $line ) {
Expand Down Expand Up @@ -186,12 +208,15 @@ public function compose_fee_break_down() {
* @return string
*/
public function compose_net_string(): string {
$data = $this->captured_event['transaction_details'];
$data = $this->captured_event['transaction_details'];
$fee_rates = $this->captured_event['fee_rates'] ?? [];
$fee_refunded = ! empty( $fee_rates['fee_refunded'] );

// Determine the type of payment and select the appropriate amounts and currencies.
if ( $this->is_fx_event() ) {
// For fx events, we need the store amount and currency to display the net amount
// in the store currency.
if ( $this->is_fx_event() || $fee_refunded ) {
// For fx events and refunded-fee events, use store amounts.
// When the fee was refunded, store_fee holds only Stripe's processing
// fee while customer_fee was zeroed out, so store values are correct.
$amount = $data['store_amount'];
$captured_amount = $data['store_amount_captured'];
$fee = $data['store_fee'];
Expand Down
33 changes: 31 additions & 2 deletions includes/class-wc-payments-order-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,20 @@ function ( array $event ) {
return;
}

// For refunded Amazon Pay application fees, the captured event's
// store_fee carries the Stripe processing fee that the merchant
// actually pays. Use it to backfill the transaction-fee meta so
// the order page shows the real fee instead of hiding the row.
$fee_refunded = ! empty( $captured_event['fee_rates']['fee_refunded'] );
$store_fee_cents = $captured_event['transaction_details']['store_fee'] ?? 0;
$store_currency = $captured_event['transaction_details']['store_currency'] ?? $order->get_currency();
if ( $fee_refunded && $store_fee_cents > 0 ) {
$order->update_meta_data(
self::WCPAY_TRANSACTION_FEE_META_KEY,
WC_Payments_Utils::interpret_stripe_amount( (int) $store_fee_cents, $store_currency )
);
}

$details = ( new WC_Payments_Captured_Event_Note( $captured_event ) )->generate_html_note();

// Add fee breakdown details to the note.
Expand Down Expand Up @@ -1409,9 +1423,21 @@ public function attach_transaction_fee_to_order( $order, $charge ) {
// Only set transaction fee if the charge was actually captured.
// Canceled authorizations should not have fees since no payment was processed.
if ( $charge && null !== $charge->get_application_fee_amount() && $charge->is_captured() ) {
// Non-card Amazon Pay transactions have the application fee refunded
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Server zeros application_fee_amount in the forwarded webhook. Without these, the client silently ignores the zeroed value and the order-page meta stays at its pre-refund amount.

Image

// server-side; record 0 up front so the order page never displays a
// pre-refund fee while waiting for the forwarded webhook to arrive.
$pm_details = $charge->get_payment_method_details() ?? [];
$pm_type = $pm_details['type'] ?? null;
$funding = $pm_details['amazon_pay']['funding']['type'] ?? null;
$is_refunded = 'amazon_pay' === $pm_type && 'card' !== $funding;

$fee_amount = $is_refunded
? 0
: WC_Payments_Utils::interpret_stripe_amount( $charge->get_application_fee_amount(), $charge->get_currency() );

$order->update_meta_data(
self::WCPAY_TRANSACTION_FEE_META_KEY,
WC_Payments_Utils::interpret_stripe_amount( $charge->get_application_fee_amount(), $charge->get_currency() )
$fee_amount
);
$order->save_meta_data();
}
Expand Down Expand Up @@ -2329,8 +2355,11 @@ private function get_intent_data( WC_Payments_API_Abstract_Intention $intent ):
* @return void
*/
private function enqueue_add_fee_breakdown_to_order_notes( WC_Order $order, string $intent_id ) {
// Delay by 15 seconds to allow the server to process webhooks and
// adjust fee data (e.g., Amazon Pay non-card fee refunds) before the
// timeline is fetched for the order note.
WC_Payments::get_action_scheduler_service()->schedule_job(
time(),
time() + 15,
self::ADD_FEE_BREAKDOWN_TO_ORDER_NOTES,
[
'order_id' => $order->get_id(),
Expand Down
21 changes: 11 additions & 10 deletions includes/class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -501,22 +501,23 @@ private function process_webhook_payment_intent_succeeded( $event_body ) {
$meta_data_to_update['_stripe_mandate_id'] = $mandate_id;
}

$application_fee_amount = $charges_data[0]['application_fee_amount'] ?? null;

if ( $application_fee_amount ) {
$fee = WC_Payments_Utils::interpret_stripe_amount( $application_fee_amount, $currency );
$meta_data_to_update['_wcpay_transaction_fee'] = $fee;

$charge_amount = WC_Payments_Utils::interpret_stripe_amount( $charge_amount, $currency );
$meta_data_to_update['_wcpay_net'] = $charge_amount - $fee;
}

foreach ( $meta_data_to_update as $key => $value ) {
// Override existing meta data with incoming values, if present.
if ( $value ) {
$order->update_meta_data( $key, $value );
}
}

// Fee/net are written directly (not via the loop above) because they
// can legitimately be 0 — the loop's truthy check would drop those.
$application_fee_amount = $charges_data[0]['application_fee_amount'] ?? null;
if ( null !== $application_fee_amount ) {
$fee = WC_Payments_Utils::interpret_stripe_amount( (int) $application_fee_amount, $currency );
$charge_amount = WC_Payments_Utils::interpret_stripe_amount( $charge_amount, $currency );
$order->update_meta_data( '_wcpay_transaction_fee', $fee );
$order->update_meta_data( '_wcpay_net', $charge_amount - $fee );
}

// Save the order after updating the meta data values.
$order->save();

Expand Down
94 changes: 94 additions & 0 deletions tests/unit/test-class-wc-payments-order-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -1494,6 +1494,60 @@ public function test_attach_transaction_fee_to_order_uncaptured_charge() {
$this->order_service->attach_transaction_fee_to_order( $mock_order, $charge );
}

public function test_attach_transaction_fee_to_order_amazon_pay_non_card() {
$order = WC_Helper_Order::create_order();
$charge = new WC_Payments_API_Charge(
'ch_mock',
1500,
new DateTime(),
[
'type' => 'amazon_pay',
'amazon_pay' => [
'funding' => [ 'type' => null ],
],
],
null,
null,
null,
102,
[],
[],
'usd'
);
$charge->set_captured( true );
$this->order_service->attach_transaction_fee_to_order( $order, $charge );

// Fee should be recorded as 0 up front; the server refunds it async.
$this->assertEquals( 0, $order->get_meta( '_wcpay_transaction_fee', true ) );
}

public function test_attach_transaction_fee_to_order_amazon_pay_card_passthrough() {
$order = WC_Helper_Order::create_order();
$charge = new WC_Payments_API_Charge(
'ch_mock',
1500,
new DateTime(),
[
'type' => 'amazon_pay',
'amazon_pay' => [
'funding' => [ 'type' => 'card' ],
],
],
null,
null,
null,
102,
[],
[],
'usd'
);
$charge->set_captured( true );
$this->order_service->attach_transaction_fee_to_order( $order, $charge );

// Card-funded Amazon Pay keeps the application fee as normal.
$this->assertEquals( 1.02, $order->get_meta( '_wcpay_transaction_fee', true ) );
}

public function test_add_note_and_metadata_for_created_refund_successful_fully_refunded(): void {
$order = WC_Helper_Order::create_order();
$order->save();
Expand Down Expand Up @@ -2101,4 +2155,44 @@ public function test_add_fee_breakdown_returns_early_when_no_captured_event() {
$notes_after = wc_get_order_notes( [ 'order_id' => $this->order->get_id() ] );
$this->assertCount( count( $notes_before ), $notes_after );
}

/**
* Test that add_fee_breakdown_to_order_notes backfills the transaction-fee
* meta with the Stripe processing fee once the application fee has been
* refunded (Amazon Pay non-card) and the balance transaction has settled.
*/
public function test_add_fee_breakdown_backfills_stripe_fee_when_application_fee_refunded() {
$this->order->update_meta_data( '_wcpay_transaction_fee', 0 );
$this->order->save();

$mock_api_client = $this->createMock( WC_Payments_API_Client::class );
$mock_api_client->expects( $this->once() )
->method( 'get_timeline' )
->willReturn(
[
'data' => [
[
'type' => 'captured',
'fee_rates' => [
'fee_refunded' => true,
],
'transaction_details' => [
'store_amount' => 1299,
'store_amount_captured' => 1299,
'store_fee' => 68,
'store_currency' => 'usd',
],
],
],
]
);

$order_service = new WC_Payments_Order_Service( $mock_api_client );
$order_service->add_fee_breakdown_to_order_notes( $this->order->get_id(), 'pi_test_123' );

// Reload the order — the service operates on its own instance loaded
// via wc_get_order(), so $this->order's cached meta is stale.
$order = wc_get_order( $this->order->get_id() );
$this->assertEquals( 0.68, $order->get_meta( '_wcpay_transaction_fee', true ) );
}
}
Loading