payment = $payment; } // ------------------------------------------------------------------ invoice data /** Keep what the invoice says, right after it was created or read back. */ public function save_invoice_data( array $invoice ): void { $p = $this->payment; $p->set_meta( Payment::META_INVOICE_ID, sanitize_text_field( (string) ( $invoice['id'] ?? '' ) ) ); $p->set_meta( Payment::META_INVOICE_STATUS, sanitize_text_field( (string) ( $invoice['status'] ?? '' ) ) ); $p->set_meta( Payment::META_PAY_URL, esc_url_raw( (string) ( $invoice['pay_url'] ?? '' ) ) ); if ( isset( $invoice['target_value_fiat'] ) && is_scalar( $invoice['target_value_fiat'] ) ) { $p->set_meta( Payment::META_INVOICE_AMOUNT, (string) $invoice['target_value_fiat'] ); } if ( ! empty( $invoice['target_currency_fiat'] ) && is_scalar( $invoice['target_currency_fiat'] ) ) { $p->set_meta( Payment::META_INVOICE_CURRENCY, strtoupper( sanitize_text_field( (string) $invoice['target_currency_fiat'] ) ) ); } if ( ! empty( $invoice['deadline_at'] ) && is_scalar( $invoice['deadline_at'] ) ) { $p->set_meta( Payment::META_DEADLINE, sanitize_text_field( (string) $invoice['deadline_at'] ) ); // Check the invoice right after it expires instead of waiting for the next pass. Cron::schedule_deadline_check( $p->id(), (string) $invoice['deadline_at'] ); } $this->remember_wallet( $invoice ); $this->remember_amounts( $invoice ); } /** * The wallet address and the coin's precision, when the invoice carries them (it does * when it was created with a symbol_id). Both are read defensively: the live shape of * target_wallet is not documented. */ private function remember_wallet( array $invoice ): void { $p = $this->payment; $wallet = $invoice['target_wallet'] ?? null; if ( is_array( $wallet ) ) { $address = $wallet['value'] ?? ( $wallet['address'] ?? '' ); if ( is_scalar( $address ) && '' !== trim( (string) $address ) ) { $p->set_meta( Payment::META_ADDRESS, sanitize_text_field( (string) $address ) ); } $precision = $wallet['symbol']['precision'] ?? ( $wallet['precision'] ?? null ); if ( is_numeric( $precision ) ) { $p->set_meta( Payment::META_PRECISION, (string) (int) $precision ); } $symbol = $wallet['symbol_id'] ?? ( $wallet['symbol']['id'] ?? '' ); if ( is_scalar( $symbol ) && '' !== trim( (string) $symbol ) && '' === Amounts::field( $invoice, 'symbol_id' ) ) { $p->set_meta( Payment::META_SYMBOL, Coins::normalise( (string) $symbol ) ); } } elseif ( is_scalar( $wallet ) && '' !== trim( (string) $wallet ) ) { $p->set_meta( Payment::META_ADDRESS, sanitize_text_field( (string) $wallet ) ); } } /** Keep the invoice's coin and coin amounts for notes and the screens. Display only. */ private function remember_amounts( ?array $invoice ): void { $p = $this->payment; $symbol = sanitize_text_field( Amounts::field( $invoice, 'symbol_id' ) ); if ( '' !== $symbol ) { $p->set_meta( Payment::META_SYMBOL, Coins::normalise( $symbol ) ); } foreach ( [ 'target_value_symbol' => Payment::META_TARGET_VALUE, 'current_value' => Payment::META_CURRENT_VALUE, 'payer_amount' => Payment::META_PAYER_AMOUNT, ] as $field => $key ) { $value = Amounts::field( $invoice, $field ); if ( null !== Amounts::split_decimal( $value ) ) { $p->set_meta( $key, Amounts::plain( $value ) ); } } // The invoice carries the hash of its latest deposit (InvoiceFullDTO.tx_hash): the // fallback when the deposit notification was missed. $hash = sanitize_text_field( Amounts::field( $invoice, 'tx_hash' ) ); if ( '' !== $hash && '' === $p->tx_hash() ) { $p->set_meta( Payment::META_TX_HASH, $hash ); } } /** Deposit data from a webhook: coin, amount, transaction hash. Only for deposits that carry money. */ public function save_deposit_data( array $payload ): void { $p = $this->payment; // For the notes only: the deposit handler writes this event's money note after the invoice status. $this->deposit_event = true; if ( ! in_array( (string) ( $payload['status'] ?? '' ), self::MONEY_STATES, true ) ) { return; } if ( ! empty( $payload['symbol_id'] ) && is_scalar( $payload['symbol_id'] ) ) { $p->set_meta( Payment::META_SYMBOL, Coins::normalise( sanitize_text_field( (string) $payload['symbol_id'] ) ) ); } if ( isset( $payload['amount'] ) && is_scalar( $payload['amount'] ) ) { $p->set_meta( Payment::META_PAID_AMOUNT, Amounts::tidy( (string) $payload['amount'] ) ); } $hash = $payload['blockchain_data']['hash'] ?? ''; if ( is_scalar( $hash ) && '' !== (string) $hash ) { $p->set_meta( Payment::META_TX_HASH, sanitize_text_field( (string) $hash ) ); } } /** The total received on an invoice (current_value), or '' when nothing readable has arrived. */ private static function received_amount( ?array $invoice ): string { $paid = Amounts::field( $invoice, 'current_value' ); return 1 === Amounts::compare( $paid, '0' ) ? Amounts::plain( $paid ) : ''; } /** Store what the invoice says has arrived on it, in total. */ private function record_received( ?array $invoice ): void { $received = self::received_amount( $invoice ); if ( '' === $received ) { return; } $this->payment->set_meta( Payment::META_PAID_AMOUNT, $received ); $symbol = sanitize_text_field( Amounts::field( $invoice, 'symbol_id' ) ); if ( '' !== $symbol ) { $this->payment->set_meta( Payment::META_SYMBOL, Coins::normalise( $symbol ) ); } } // ------------------------------------------------------------------ amounts shown /** * The amounts a note and the screens show (see Amounts::shown()). * * @param array|null $invoice The invoice read back from Cryptoway; the stored copy is used without it. */ public function shown_amounts( ?array $invoice = null ): array { $p = $this->payment; $symbol = sanitize_text_field( Amounts::field( $invoice, 'symbol_id' ) ); $symbol = '' !== $symbol ? $symbol : $p->symbol(); $target = Amounts::positive( Amounts::field( $invoice, 'target_value_symbol' ) ) ?? $p->meta( Payment::META_TARGET_VALUE ); $paid = Amounts::positive( Amounts::field( $invoice, 'current_value' ) ) ?? $p->meta( Payment::META_CURRENT_VALUE ); return Amounts::shown( $symbol, $target, $paid, $p->meta( Payment::META_PAID_AMOUNT ) ); } /** * Rows for the payment screen: invoiced and paid, and the remainder or overpayment. * * @return array Label => plain-text value. */ public function amount_rows(): array { $amounts = $this->shown_amounts(); $rows = []; if ( '' !== $amounts['invoiced'] ) { $rows[ _x( 'Invoiced', 'payment screen amount', 'cryptoway-button' ) ] = Amounts::with_coin( $amounts['invoiced'], $amounts['coin'] ); } if ( '' !== $amounts['paid'] ) { $rows[ _x( 'Paid', 'payment screen amount', 'cryptoway-button' ) ] = '' !== $amounts['invoiced'] ? $amounts['paid'] : Amounts::with_coin( $amounts['paid'], $amounts['base'] ); } if ( '' !== $amounts['rest'] ) { $rows[ _x( 'Remaining', 'payment screen amount', 'cryptoway-button' ) ] = $amounts['rest']; } if ( '' !== $amounts['over'] ) { $rows[ _x( 'Overpaid', 'payment screen amount', 'cryptoway-button' ) ] = $amounts['over']; } return $rows; } // ------------------------------------------------------------------ notes and statuses private function add_note( string $note, string $kind = 'other' ): void { $this->payment->add_history( $note ); $this->noted = $kind; } /** * Move the payment to a status with a note. A settled payment is never moved: the * note alone is written then. */ private function set_status( string $status, string $note ): void { $current = $this->payment->fresh_status(); if ( in_array( $current, Payment::SETTLED, true ) && Payment::STATUS_PAID !== $status ) { $this->add_note( $note ); return; } $this->add_note( $note ); if ( $current !== $status ) { $this->payment->set_status( $status ); // A paid invoice needs no check at its deadline any more. if ( Payment::STATUS_PAID === $status ) { wp_clear_scheduled_hook( Cron::HOOK_DEADLINE, [ $this->payment->id() ] ); } Email::notify( $this->payment, $status ); Email::payer( $this->payment, $status ); } } /** * A payment held by the AML check keeps that status until Cryptoway decides (the invoice is * executed): later expiry or deposit events only add a note, so the reason a person must look * stays visible in the list and the e-mail. */ private function is_aml_held(): bool { return Payment::STATUS_AML === $this->payment->fresh_status(); } /** "Cryptoway: could not check the payment — . It will be checked again automatically." */ private static function read_failed_note( string $reason ): string { $reason = rtrim( trim( $reason ), '.' ); if ( '' === $reason ) { $reason = __( 'Cryptoway did not return the invoice', 'cryptoway-button' ); } return sprintf( /* translators: %s: short reason, e.g. "Cryptoway is unreachable" */ __( 'Cryptoway: could not check the payment — %s. It will be checked again automatically.', 'cryptoway-button' ), $reason ); } /** A short reason for the read failure kept by explain_read_failure(), or $fallback when there is none. */ private function read_failure_reason( string $fallback ): string { $error = $this->read_error; if ( null === $error ) { return $fallback; } $data = $error->get_error_data(); $status = is_array( $data ) ? (int) ( $data['status'] ?? 0 ) : 0; if ( 'cryptoway_button_http_error' === $error->get_error_code() || $status >= 500 ) { return __( 'Cryptoway is unreachable', 'cryptoway-button' ); } if ( $status > 0 ) { $body = is_array( $data ) ? json_decode( (string) ( $data['body'] ?? '' ), true ) : null; $detail = is_array( $body ) ? ( $body['detail'] ?? '' ) : ''; $detail = is_scalar( $detail ) ? (string) $detail : (string) wp_json_encode( $detail ); $detail = sanitize_text_field( $detail ); if ( in_array( $status, [ 401, 403 ], true ) ) { return 1 === preg_match( '/\bip\b/i', $detail ) ? __( "Cryptoway rejected the keys from this server's IP address", 'cryptoway-button' ) : __( 'Cryptoway did not accept the keys', 'cryptoway-button' ); } return sprintf( 'HTTP %d', $status ); } $message = explode( ' — ', sanitize_text_field( $error->get_error_message() ) )[0]; return '' !== $message ? $message : $fallback; } /** Keep why the invoice could not be read, so the note of this event can say it. */ public function explain_read_failure( \WP_Error $error ): void { $this->read_error = $error; } // ------------------------------------------------------------------ applying events /** * Apply an invoice status to the payment. * * @param string $invoice_status Status: active|pending|executed|expired|cancelled|dirty_lock. * @param array|null $invoice Full invoice object, if available — for amount verification. * @param string $source Where it came from: webhook|cron|manual|poll. * @return bool False when another handler for this invoice was running: nothing was applied, * and a notification must be answered so that Cryptoway delivers it again. */ public function apply_invoice_status( string $invoice_status, ?array $invoice = null, string $source = 'webhook' ): bool { $p = $this->payment; $invoice_id = $p->invoice_id(); if ( ! $this->acquire_lock( $invoice_id ) ) { // Another handler for this invoice is running: it would confirm and e-mail twice. return false; } try { $previous = $p->invoice_status(); $p->set_meta( Payment::META_LAST_SYNC, (string) time() ); $this->remember_amounts( $invoice ); if ( is_array( $invoice ) ) { $this->remember_wallet( $invoice ); } // When the invoice closed, for should_recheck(). if ( in_array( $invoice_status, self::TERMINAL, true ) && '' === $p->meta( Payment::META_TERMINAL_AT ) ) { $p->set_meta( Payment::META_TERMINAL_AT, (string) time() ); } if ( $previous === $invoice_status && 'manual' !== $source && ! $this->needs_verification() ) { return true; } switch ( $invoice_status ) { case 'executed': $this->handle_executed( $invoice ); break; case 'expired': case 'cancelled': $this->handle_terminal( $invoice_status, $invoice ); break; case 'dirty_lock': // Deposit held by the AML check: a person has to look. Never a payment. if ( $p->is_settled() ) { $this->add_note( __( 'Cryptoway: the payment is held by a security check at Cryptoway. It is already confirmed — its status was left unchanged.', 'cryptoway-button' ) ); } else { $this->set_status( Payment::STATUS_AML, __( 'Cryptoway: the payment is held by the AML check. Do not deliver anything until Cryptoway decides.', 'cryptoway-button' ) ); } break; case 'pending': // Another transfer on a payment that is already confirmed changes nothing. if ( $p->is_settled() ) { $this->add_note( __( 'Cryptoway: another transfer arrived for a payment that is already confirmed. Its status was left unchanged.', 'cryptoway-button' ) ); break; } // A deposit that leaves the confirming invoice short: the deposit handler's // note_partial_payment(), called next, writes this event's one note. if ( $this->deposit_event && is_array( $invoice ) && ! $this->invoice_is_covered( $invoice ) ) { break; } if ( Payment::STATUS_PENDING === $p->fresh_status() ) { $this->set_status( Payment::STATUS_CONFIRMING, __( 'Cryptoway: money arrived, confirming.', 'cryptoway-button' ) ); $this->noted = 'confirming'; } else { $this->add_note( __( 'Cryptoway: money arrived, confirming.', 'cryptoway-button' ), 'confirming' ); } break; case 'active': default: break; } // The stored status records what was applied, so it is written after the // switch: if anything above fails, the re-delivered event is applied again. A closed // invoice is never stored as open again by a late "pending" deposit event. if ( ! ( in_array( $previous, self::TERMINAL, true ) && ! in_array( $invoice_status, self::TERMINAL, true ) ) ) { $p->set_meta( Payment::META_INVOICE_STATUS, $invoice_status ); } } finally { $this->release_lock( $invoice_id ); } return true; } /** The invoice is executed: confirm the payment only when the read-back amounts cover it. */ private function handle_executed( ?array $invoice ): void { $p = $this->payment; $status = $p->fresh_status(); if ( in_array( $status, Payment::SETTLED, true ) ) { $this->clear_unverified(); return; } // Never confirm on the status alone. An invoice read back as not executed // means the full amount has not arrived. if ( is_array( $invoice ) && ! empty( $invoice['status'] ) && 'executed' !== (string) $invoice['status'] ) { $this->mark_unverified( __( 'Cryptoway has not closed the invoice yet', 'cryptoway-button' ) ); return; } // Without the invoice amount (API unreachable, or a 2xx body that is not an // invoice) nothing can be verified: hold instead. if ( ! is_array( $invoice ) || '' === (string) ( $invoice['target_value_fiat'] ?? '' ) ) { $this->mark_unverified( is_array( $invoice ) ? __( 'Cryptoway did not return the invoice amount', 'cryptoway-button' ) : $this->read_failure_reason( __( 'Cryptoway did not return the invoice', 'cryptoway-button' ) ) ); return; } $mismatch = $this->check_amount( $invoice ); if ( null !== $mismatch ) { $this->clear_unverified(); $this->set_status( Payment::STATUS_HOLD, $mismatch ); return; } // Executed with fewer coins than asked is an underpayment. Both amounts are in // coin units and include markup and fee; when either is missing nothing is decided. $short = $this->coin_shortfall_note( $invoice ); if ( '' !== $short ) { $this->clear_unverified(); $this->set_status( Payment::STATUS_UNDERPAID, $short ); return; } $this->clear_unverified(); $this->record_received( $invoice ); // Money that arrives after the time ran out is a late payment: a person decides. So is a payment // already marked late, or one whose invoice had closed before it was covered (a top-up after // the deadline): a later "executed" does not confirm it on its own. $late = in_array( $status, [ Payment::STATUS_EXPIRED, Payment::STATUS_CANCELLED, Payment::STATUS_LATE ], true ) || in_array( $p->invoice_status(), [ 'expired', 'cancelled' ], true ); if ( $late ) { if ( Payment::STATUS_LATE === $status ) { $this->add_note( $this->late_payment_note( $invoice ) ); } else { $this->set_status( Payment::STATUS_LATE, $this->late_payment_note( $invoice ) ); } return; } $p->set_meta( Payment::META_PAID_AT, (string) time() ); $this->set_status( Payment::STATUS_PAID, $this->confirmed_note( $invoice ) ); } /** The note for a confirmed payment: what was invoiced and paid, and any overpayment. */ private function confirmed_note( ?array $invoice ): string { $amounts = $this->shown_amounts( $invoice ); $phrase = Amounts::phrase( $amounts ); if ( '' === $phrase ) { return __( 'Cryptoway: payment confirmed.', 'cryptoway-button' ); } if ( '' !== $amounts['over'] && $this->is_overpaid( $invoice ) ) { return sprintf( /* translators: %s: "invoiced 14.39 USDT (BEP20), paid 15.00, overpaid 0.61" */ __( 'Cryptoway: %s. Payment confirmed, refund the overpayment to the payer from the Cryptoway account.', 'cryptoway-button' ), $phrase ); } return sprintf( /* translators: %s: "invoiced 14.39 USDT (BEP20), paid 14.39" */ __( 'Cryptoway: %s. Payment confirmed.', 'cryptoway-button' ), $phrase ); } /** The note for money that arrived after the time ran out: the same words in every Cryptoway plugin. */ private function late_payment_note( ?array $invoice ): string { $phrase = Amounts::phrase( $this->shown_amounts( $invoice ) ); if ( '' === $phrase ) { return __( 'Cryptoway: payment after the time ran out. The money is with Cryptoway — settle it by hand.', 'cryptoway-button' ); } return sprintf( /* translators: %s: "Invoiced 14.39 USDT (BEP20), paid 14.39" */ __( 'Cryptoway: payment after the time ran out. %s. The money is with Cryptoway — settle it by hand.', 'cryptoway-button' ), Amounts::sentence_case( $phrase ) ); } /** Whether the overpayment is worth refunding: proportional, above one step of the coin's precision. */ private function is_overpaid( ?array $invoice ): bool { if ( ! is_array( $invoice ) ) { return false; } $target = (string) ( $invoice['target_value_symbol'] ?? '' ); $paid = (string) ( $invoice['current_value'] ?? '' ); if ( ! is_numeric( $target ) || ! is_numeric( $paid ) || (float) $target <= 0 ) { return false; } $excess = (float) $paid - (float) $target; if ( $excess <= 0 ) { return false; } // One step of the coin's precision is rounding the payer could not avoid // (5.00046 sent as 5.1 on a one-decimal coin), not an overpayment. $precision = $invoice['target_wallet']['symbol']['precision'] ?? null; if ( is_numeric( $precision ) && $excess <= pow( 10, -(int) $precision ) + 1e-9 ) { return false; } return ( $excess / (float) $target ) >= self::OVERPAYMENT_THRESHOLD; } /** * Money arrived but does not cover the invoice yet: the payment is underpaid at once, * so the site owner sees that money is involved. */ public function note_partial_payment( ?array $invoice = null ): void { $status = $this->payment->fresh_status(); if ( in_array( $status, Payment::SETTLED, true ) ) { $this->add_note( __( 'Cryptoway: a deposit notification arrived for a payment that is already confirmed. Its status was left unchanged.', 'cryptoway-button' ) ); return; } $this->record_received( $invoice ); if ( $this->is_aml_held() ) { $this->add_note( $this->partial_payment_note( $invoice ) ); return; } $invoice_status = Amounts::field( $invoice, 'status' ); // The invoice has closed meanwhile: the rest will not come on it. if ( in_array( $invoice_status, [ 'expired', 'cancelled' ], true ) ) { $this->set_status( Payment::STATUS_LATE, $this->closed_with_money_note( $invoice_status, $invoice ) ); return; } if ( Payment::STATUS_UNDERPAID === $status ) { // A note this event already wrote has said it. if ( 'other' !== $this->noted ) { $this->add_note( $this->partial_payment_note( $invoice ) ); } return; } $this->set_status( Payment::STATUS_UNDERPAID, $this->partial_payment_note( $invoice ) ); } /** The note for money that does not cover the invoice. */ private function partial_payment_note( ?array $invoice ): string { $phrase = Amounts::phrase( $this->shown_amounts( $invoice ) ); if ( '' === $phrase ) { return __( 'Cryptoway: money arrived, but not the full amount. The payment is waiting for the rest.', 'cryptoway-button' ); } return sprintf( /* translators: %s: "invoiced 14.39 USDT (BEP20), paid 7.15, remaining 7.24" */ __( 'Cryptoway: %s. The payment is waiting for the rest.', 'cryptoway-button' ), $phrase ); } /** * The full amount is in, but Cryptoway has not closed the invoice yet. Only a note: * the invoice notification or the scheduled check confirms the payment. */ public function note_awaiting_confirmation(): void { if ( '' !== $this->noted || 'pending' === $this->payment->invoice_status() ) { return; } $status = $this->payment->fresh_status(); if ( Payment::STATUS_PENDING === $status ) { $this->set_status( Payment::STATUS_CONFIRMING, __( 'Cryptoway: money arrived, confirming.', 'cryptoway-button' ) ); $this->noted = 'confirming'; return; } // Already said: a second deposit notification for the same transfer (pending, then // executed) brings nothing new (live 2026-10-12: it used to write the note twice). if ( Payment::STATUS_CONFIRMING === $status ) { $this->noted = 'confirming'; return; } $this->add_note( __( 'Cryptoway: money arrived, confirming.', 'cryptoway-button' ), 'confirming' ); } /** * A deposit landed on an invoice that is already closed (expired or cancelled): the * payment is late, whatever it was before, and nothing is confirmed. */ public function hold_late_deposit( array $payload ): void { if ( $this->payment->is_settled() ) { $this->add_note( __( 'Cryptoway: a deposit notification arrived for a payment that is already confirmed. Its status was left unchanged.', 'cryptoway-button' ) ); return; } $note = $this->closed_with_money_note( $this->payment->invoice_status(), null ); if ( Payment::STATUS_LATE === $this->payment->fresh_status() || $this->is_aml_held() ) { $this->add_note( $note ); return; } $this->set_status( Payment::STATUS_LATE, $note ); } /** Whether the coins on the invoice cover what it asks for. */ public function invoice_is_covered( ?array $invoice ): bool { if ( ! is_array( $invoice ) ) { return false; } $target = (string) ( $invoice['target_value_symbol'] ?? '' ); $paid = (string) ( $invoice['current_value'] ?? '' ); if ( ! is_numeric( $target ) || ! is_numeric( $paid ) || (float) $target <= 0 ) { return false; } // A hair of tolerance for binary rounding, not for missing money. return ( (float) $paid ) + 0.00000001 >= (float) $target; } /** Money arrived, but the invoice status could not be read. */ public function note_deposit_unconfirmed( ?\WP_Error $error = null ): void { if ( $this->payment->is_settled() ) { $this->add_note( __( 'Cryptoway: money arrived for a payment that is already confirmed, and the invoice state could not be checked right now. Its status was left unchanged.', 'cryptoway-button' ) ); return; } if ( null !== $error ) { $this->explain_read_failure( $error ); } $this->mark_unverified( $this->read_failure_reason( __( 'Cryptoway did not return the invoice', 'cryptoway-button' ) ), true ); } /** * The note for an executed invoice with fewer coins than it asked for. * * @return string Note text, or '' when the amounts cover the invoice or either is missing. */ private function coin_shortfall_note( ?array $invoice ): string { $target = Amounts::field( $invoice, 'target_value_symbol' ); $paid = Amounts::field( $invoice, 'current_value' ); if ( -1 !== Amounts::compare( $paid, $target ) ) { return ''; } $this->record_received( $invoice ); return sprintf( /* translators: %s: "invoiced 14.39 USDT (BEP20), paid 7.15, remaining 7.24" */ __( 'Cryptoway: %s. The invoice was closed short — settle it by hand.', 'cryptoway-button' ), Amounts::phrase( $this->shown_amounts( $invoice ) ) ); } /** * The invoice expired or was cancelled: close the payment, unless it is already * confirmed or money has arrived for it. */ private function handle_terminal( string $invoice_status, ?array $invoice = null ): void { $p = $this->payment; if ( $p->is_settled() ) { $this->add_note( ( 'cancelled' === $invoice_status ? __( 'Cryptoway: the invoice was cancelled.', 'cryptoway-button' ) : __( 'Cryptoway: the time to pay has run out.', 'cryptoway-button' ) ) . ' ' . __( 'The payment is already confirmed, its status was left unchanged.', 'cryptoway-button' ) ); return; } // A payment with money on it is never closed as unpaid: it waits for a person. // current_value counts even if a deposit notification was missed. $received = self::received_amount( $invoice ); if ( '' !== $received ) { $this->record_received( $invoice ); } elseif ( null !== Amounts::positive( $p->meta( Payment::META_PAID_AMOUNT ) ) ) { $received = $p->meta( Payment::META_PAID_AMOUNT ); } if ( '' !== $received ) { $note = $this->closed_with_money_note( $invoice_status, $invoice ); if ( Payment::STATUS_LATE === $p->fresh_status() || $this->is_aml_held() ) { $this->add_note( $note ); return; } $this->set_status( Payment::STATUS_LATE, $note ); return; } // Money held by the AML check was on the invoice even if current_value says nothing. if ( $this->is_aml_held() ) { $this->add_note( 'cancelled' === $invoice_status ? __( 'Cryptoway: the invoice was cancelled. The payment stays held by the security check until Cryptoway decides.', 'cryptoway-button' ) : __( 'Cryptoway: the time to pay ran out. The payment stays held by the security check until Cryptoway decides.', 'cryptoway-button' ) ); return; } $closed = 'cancelled' === $invoice_status ? Payment::STATUS_CANCELLED : Payment::STATUS_EXPIRED; if ( $closed === $p->fresh_status() ) { return; } $this->set_status( $closed, 'cancelled' === $invoice_status ? __( 'Cryptoway: the invoice was cancelled, nothing was paid.', 'cryptoway-button' ) : __( 'Cryptoway: the time to pay ran out, nothing was paid.', 'cryptoway-button' ) ); } /** The note for money on an invoice that expired or was cancelled: the same words in every Cryptoway plugin. */ private function closed_with_money_note( string $invoice_status, ?array $invoice ): string { $phrase = Amounts::sentence_case( Amounts::phrase( $this->shown_amounts( $invoice ) ) ); if ( 'cancelled' === $invoice_status ) { return '' === $phrase ? __( 'Cryptoway: the invoice was cancelled. The money is with Cryptoway — settle it by hand.', 'cryptoway-button' ) : sprintf( /* translators: %s: "Invoiced 14.39 USDT (BEP20), paid 7.15, remaining 7.24" */ __( 'Cryptoway: the invoice was cancelled. %s. The money is with Cryptoway — settle it by hand.', 'cryptoway-button' ), $phrase ); } return '' === $phrase ? __( 'Cryptoway: the time to pay ran out. The money is with Cryptoway — settle it by hand.', 'cryptoway-button' ) : sprintf( /* translators: %s: "Invoiced 14.39 USDT (BEP20), paid 7.15, remaining 7.24" */ __( 'Cryptoway: the time to pay ran out. %s. The money is with Cryptoway — settle it by hand.', 'cryptoway-button' ), $phrase ); } /** The payment is waiting until the amount can be verified with Cryptoway. */ public function needs_verification(): bool { return 'yes' === $this->payment->meta( Payment::META_UNVERIFIED ); } /** * Whether the scheduled check should still ask about this payment: always while the * invoice is open; once it is closed, only while it waits for a check Cryptoway did not * answer — at every pass for TERMINAL_GRACE, then less and less often (a twelfth of the * time since closing, at most every VERIFY_BACKOFF_MAX). */ public function should_recheck(): bool { $status = $this->payment->invoice_status(); if ( ! in_array( $status, self::TERMINAL, true ) ) { return true; } // A held payment on a closed invoice is read again too, less and less often: Cryptoway's // decision on it may come while its notification is lost. if ( ! $this->needs_verification() && ! $this->is_aml_held() ) { return false; } $closed_for = time() - $this->terminal_since( $status ); if ( $closed_for < self::TERMINAL_GRACE ) { return true; } $since_last = time() - (int) $this->payment->meta( Payment::META_LAST_SYNC ); $interval = (int) min( self::VERIFY_BACKOFF_MAX, max( 10 * MINUTE_IN_SECONDS, intdiv( $closed_for, 12 ) ) ); return $since_last >= $interval; } /** When the invoice closed, as a timestamp. */ private function terminal_since( string $status ): int { $p = $this->payment; $stamp = (int) $p->meta( Payment::META_TERMINAL_AT ); if ( $stamp > 0 ) { return $stamp; } if ( 'expired' === $status ) { $deadline = strtotime( $p->deadline() ); if ( false !== $deadline && $deadline > 0 ) { $p->set_meta( Payment::META_TERMINAL_AT, (string) $deadline ); return $deadline; } } $now = time(); $p->set_meta( Payment::META_TERMINAL_AT, (string) $now ); return $now; } /** * @param string $reason Why the payment could not be checked, for the note. * @param bool $repeat Note it even when the payment already waits for the check. */ private function mark_unverified( string $reason, bool $repeat = false ): void { $p = $this->payment; $status = $p->fresh_status(); if ( in_array( $status, Payment::SETTLED, true ) ) { $this->add_note( __( 'Cryptoway: an event for this payment could not be verified, but it is already confirmed. Its status was left unchanged.', 'cryptoway-button' ) ); return; } $waiting = $this->needs_verification(); $p->set_meta( Payment::META_UNVERIFIED, 'yes' ); $note = self::read_failed_note( $reason ); if ( Payment::STATUS_HOLD === $status || Payment::STATUS_AML === $status ) { if ( ! $waiting || $repeat ) { $this->add_note( $note ); } return; } $this->set_status( Payment::STATUS_HOLD, $note ); } private function clear_unverified(): void { if ( $this->needs_verification() ) { $this->payment->delete_meta( Payment::META_UNVERIFIED ); } } /** * Verify the invoice amount and currency against what the button asked for. * * @return string|null Mismatch note text, or null when everything checks out. */ private function check_amount( ?array $invoice ): ?string { $p = $this->payment; $expected_total = (float) $p->amount(); $expected_currency = strtoupper( $p->currency() ); $amount = $invoice['target_value_fiat'] ?? $p->meta( Payment::META_INVOICE_AMOUNT ); $currency = $invoice['target_currency_fiat'] ?? $p->meta( Payment::META_INVOICE_CURRENCY ); if ( '' === (string) $amount || null === $amount ) { $this->add_note( __( 'Cryptoway: the amount could not be double-checked — it was not reported back.', 'cryptoway-button' ) ); return null; } $amount = (float) $amount; // One-sided on purpose: less than asked is a mismatch; more is a markup or a // payer fee set in the project, and is accepted. if ( $expected_total - $amount > self::FIAT_TOLERANCE ) { return sprintf( /* translators: 1: invoice amount, 2: requested amount */ __( 'Cryptoway: the amount paid (%1$s) does not match the amount asked for (%2$s). The payment needs a review.', 'cryptoway-button' ), number_format( $amount, 2, '.', '' ), number_format( $expected_total, 2, '.', '' ) ); } if ( '' !== (string) $currency && strtoupper( (string) $currency ) !== $expected_currency ) { return sprintf( /* translators: 1: invoice currency, 2: requested currency */ __( 'Cryptoway: the currency paid (%1$s) does not match the currency asked for (%2$s). The payment needs a review.', 'cryptoway-button' ), strtoupper( (string) $currency ), $expected_currency ); } return null; } /** * Read the invoice from the API and apply its status. The API is the source of truth: * Cryptoway does not re-send a webhook that got a 2xx. */ public function sync_status_from_api( string $source = 'manual' ): bool { $invoice_id = $this->payment->invoice_id(); if ( '' === $invoice_id ) { if ( 'manual' === $source ) { $this->add_note( __( 'Cryptoway: this payment has no invoice attached to it.', 'cryptoway-button' ) ); } return false; } // A visitor's page waits for this answer: a short timeout and no retries, so an outage at // Cryptoway cannot hold the site's workers (the scheduled check retries in the background). $client = ApiClient::from_settings(); $result = ( 'poll' === $source ? $client->quick() : $client )->get_invoice( $invoice_id ); if ( is_wp_error( $result ) ) { $this->explain_read_failure( $result ); if ( 'poll' !== $source ) { $this->add_note( self::read_failed_note( $this->read_failure_reason( '' ) ) ); } $this->payment->set_meta( Payment::META_LAST_SYNC, (string) time() ); return false; } $invoice_status = (string) ( $result['status'] ?? '' ); if ( '' === $invoice_status ) { if ( 'poll' !== $source ) { $this->add_note( self::read_failed_note( __( 'Cryptoway did not return the invoice status', 'cryptoway-button' ) ) ); } $this->payment->set_meta( Payment::META_LAST_SYNC, (string) time() ); return false; } if ( ! $this->apply_invoice_status( $invoice_status, $result, $source ) ) { // A notification for this invoice was being handled at that moment: nothing was applied. if ( 'manual' === $source ) { $this->add_note( __( 'Cryptoway: the payment was being updated at that moment. Check again in a minute.', 'cryptoway-button' ) ); } return false; } if ( 'manual' === $source && '' === $this->noted ) { $this->add_note( sprintf( /* translators: %s - invoice status returned by the API */ __( 'Cryptoway: status checked manually → %s', 'cryptoway-button' ), $invoice_status ) ); } return true; } // ------------------------------------------------------------------ duplicate events /** Whether this event has already been applied. */ public function is_duplicate_event( string $fingerprint ): bool { return in_array( $fingerprint, $this->payment->meta_array( Payment::META_EVENTS ), true ); } /** Record an event as applied. Call only after the work has succeeded. */ public function remember_event( string $fingerprint ): void { $payment = $this->payment; // Read and written under the payment's own lock: two notifications at once keep both. Lock::run( Payment::meta_lock_key( $payment->id() ), static function () use ( $payment, $fingerprint ): void { $payment->forget_cached_meta(); $seen = $payment->meta_array( Payment::META_EVENTS ); if ( in_array( $fingerprint, $seen, true ) ) { return; } $seen[] = $fingerprint; $payment->set_meta( Payment::META_EVENTS, array_slice( $seen, -20 ) ); } ); } // ------------------------------------------------------------------ locks /** How long a lock may be held before it is treated as abandoned. */ private const LOCK_TTL = 60; private static function lock_key( string $invoice_id ): string { return 'cryptoway_button_lock_' . md5( $invoice_id ); } /** Mutual exclusion while an event for this invoice is processed. */ private function acquire_lock( string $invoice_id ): bool { return '' === $invoice_id || Lock::acquire( self::lock_key( $invoice_id ), self::LOCK_TTL ); } private function release_lock( string $invoice_id ): void { if ( '' !== $invoice_id ) { Lock::release( self::lock_key( $invoice_id ) ); } } }