'13', 'cc' => '13', 'cedula' => '13', 'cédula' => '13', 'cedula de ciudadania' => '13', 'cédula de ciudadanía' => '13', 'ciudadania' => '13', '31' => '31', 'nit' => '31', 'rut' => '31', 'empresa' => '31', 'company' => '31', '22' => '22', 'ce' => '22', 'cedula de extranjeria' => '22', 'cédula de extranjería' => '22', 'extranjeria' => '22', '41' => '41', 'pa' => '41', 'pas' => '41', 'pasaporte' => '41', 'passport' => '41', '42' => '42', 'die' => '42', 'documento de identificacion extranjero' => '42', '47' => '47', 'pep' => '47', 'permiso especial de permanencia' => '47', '48' => '48', 'ppt' => '48', 'permiso por proteccion temporal' => '48', 'permiso por protección temporal' => '48', '50' => '50', 'nit de otro pais' => '50', 'nit extranjero' => '50', '11' => '11', 'rc' => '11', 'registro civil' => '11', '12' => '12', 'ti' => '12', 'tarjeta de identidad' => '12', '21' => '21', 'te' => '21', 'tarjeta de extranjeria' => '21', 'tarjeta de extranjería' => '21', '91' => '91', 'nuip' => '91', ); /** Default tax-class map; the settings page overrides per class slug. */ const DEFAULT_TAX_MAP = array( '' => 'iva19', 'standard' => 'iva19', 'reduced-rate' => 'iva5', 'zero-rate' => 'exento', ); /** Selectable DIAN treatments (settings dropdown). */ const TAX_TREATMENTS = array( 'iva19' => array( 'tax_code' => '01', 'tax_percent' => 19, 'reason' => null ), 'iva5' => array( 'tax_code' => '01', 'tax_percent' => 5, 'reason' => null ), 'iva0' => array( 'tax_code' => '01', 'tax_percent' => 0, 'reason' => null ), 'exento' => array( 'tax_code' => '01', 'tax_percent' => 0, 'reason' => 'exento' ), 'excluido' => array( 'tax_code' => '01', 'tax_percent' => 0, 'reason' => 'excluido' ), 'inc8' => array( 'tax_code' => '04', 'tax_percent' => 8, 'reason' => null ), 'inc4' => array( 'tax_code' => '04', 'tax_percent' => 4, 'reason' => null ), // No 'bolsas' (INC bolsas 22): it is a per-unit tax miPlata does not // issue yet (the API answers 422). A saved 'bolsas' mapping falls back // to the default treatment (treatment_for_class). 'native' => null, // Percent from the order's own tax lines, IVA code. ); /** WooCommerce gateway id → the gateway label miPlata understands. */ public static function gateway_id( string $wc_gateway ): string { $g = strtolower( trim( $wc_gateway ) ); if ( '' === $g ) { return ''; } if ( 'miplata_wava' === $g || 'miplata_paypal' === $g ) { return substr( $g, 8 ); } if ( false !== strpos( $g, 'wava' ) ) { return 'wava'; } if ( false !== strpos( $g, 'paypal' ) || 'ppcp-gateway' === $g || 'ppcp' === $g ) { return 'paypal'; } if ( 'bacs' === $g || false !== strpos( $g, 'transfer' ) || false !== strpos( $g, 'pse' ) ) { return 'transfer'; } if ( 'cod' === $g || 'cheque' === $g ) { return 'cod'; } return preg_replace( '/[^a-z0-9_-]/', '', $g ); } /** Pure: `$raw` label/code → DIAN type or '' when unknown. */ public static function doc_type_from( string $raw ): string { $k = strtolower( trim( $raw ) ); if ( '' === $k ) { return ''; } if ( isset( self::DOC_TYPE_ALIASES[ $k ] ) ) { return self::DOC_TYPE_ALIASES[ $k ]; } $k2 = preg_replace( '/[^a-z0-9 ]/u', '', $k ); return self::DOC_TYPE_ALIASES[ $k2 ] ?? ''; } /** Treatment code for a class slug from the merged settings map. */ public static function treatment_for_class( string $tax_class, array $tax_map ): string { $slug = '' === $tax_class ? '' : strtolower( $tax_class ); // WooCommerce's standard class has an empty slug; settings key it as `standard`. if ( '' === $slug && isset( $tax_map['standard'] ) && array_key_exists( $tax_map['standard'], self::TAX_TREATMENTS ) ) { return $tax_map['standard']; } if ( isset( $tax_map[ $slug ] ) && array_key_exists( $tax_map[ $slug ], self::TAX_TREATMENTS ) ) { return $tax_map[ $slug ]; } return self::DEFAULT_TAX_MAP[ $slug ] ?? 'native'; } /** * Read the native order into a plain array. Only this method touches * WooCommerce; everything it returns is scalar. * * @param \WC_Order $order Order. * @return array */ public static function extract( $order ): array { $meta = static function ( $key ) use ( $order ) { $v = $order->get_meta( $key, true ); if ( '' === $v || null === $v ) { $v = $order->get_meta( ltrim( $key, '_' ), true ); } return is_scalar( $v ) ? (string) $v : ''; }; $lines = array(); foreach ( $order->get_items( 'line_item' ) as $item ) { /** @var \WC_Order_Item_Product $item */ $product = $item->get_product(); $qty = (float) $item->get_quantity(); $lines[] = array( 'kind' => 'product', 'name' => $item->get_name(), 'sku' => $product ? (string) $product->get_sku() : '', 'quantity' => $qty, 'total' => (float) $item->get_total(), // After coupons, excl. tax. 'subtotal' => (float) $item->get_subtotal(), // Before coupons, excl. tax. 'total_tax' => (float) $item->get_total_tax(), 'tax_class' => (string) $item->get_tax_class(), 'is_service' => $product ? ( $product->is_virtual() || $product->is_downloadable() ) : false, ); } foreach ( $order->get_items( 'shipping' ) as $item ) { /** @var \WC_Order_Item_Shipping $item */ $lines[] = array( 'kind' => 'shipping', 'name' => $item->get_name() ? $item->get_name() : 'Envío', 'sku' => '', 'quantity' => 1, 'total' => (float) $item->get_total(), 'subtotal' => (float) $item->get_total(), 'total_tax' => (float) $item->get_total_tax(), 'tax_class' => (string) $item->get_tax_class(), 'is_service' => true, ); } foreach ( $order->get_items( 'fee' ) as $item ) { /** @var \WC_Order_Item_Fee $item */ $lines[] = array( 'kind' => (float) $item->get_total() < 0 ? 'discount' : 'fee', 'name' => $item->get_name() ? $item->get_name() : 'Cargo', 'sku' => '', 'quantity' => 1, 'total' => (float) $item->get_total(), 'subtotal' => (float) $item->get_total(), 'total_tax' => (float) $item->get_total_tax(), 'tax_class' => (string) $item->get_tax_class(), 'is_service' => true, ); } $doc_number = ''; $doc_type = ''; foreach ( self::DOC_NUMBER_KEYS as $k ) { $v = $meta( $k ); if ( '' !== trim( $v ) ) { $doc_number = $v; break; } } foreach ( self::DOC_TYPE_KEYS as $k ) { $v = $meta( $k ); if ( '' !== trim( $v ) ) { $doc_type = $v; break; } } $paid_date = $order->get_date_paid(); return array( 'id' => (int) $order->get_id(), 'number' => (string) $order->get_order_number(), 'currency' => (string) $order->get_currency(), 'total' => (float) $order->get_total(), 'gateway' => (string) $order->get_payment_method(), 'status' => (string) $order->get_status(), 'is_paid' => $order->is_paid(), 'paid_at' => $paid_date ? $paid_date->date( DATE_ATOM ) : '', 'customer_note' => (string) $order->get_customer_note(), 'order_url' => (string) $order->get_view_order_url(), 'billing' => array( 'first_name' => (string) $order->get_billing_first_name(), 'last_name' => (string) $order->get_billing_last_name(), 'company' => (string) $order->get_billing_company(), 'email' => (string) $order->get_billing_email(), 'phone' => (string) $order->get_billing_phone(), 'address_1' => (string) $order->get_billing_address_1(), 'address_2' => (string) $order->get_billing_address_2(), 'city' => (string) $order->get_billing_city(), 'state' => (string) $order->get_billing_state(), 'postcode' => (string) $order->get_billing_postcode(), 'country' => (string) $order->get_billing_country(), ), 'doc_number' => $doc_number, 'doc_type' => $doc_type, 'invoice_to' => $meta( '_miplata_invoice_to' ), 'city_code' => $meta( '_miplata_city_code' ), 'lines' => $lines, ); } /** * Pure mapping. `$settings` keys: tax_map (slug→treatment), delivery, * on_paid, invoice_type, default_doc_type. * * @return array spec body for POST /payments/checkouts */ public static function build_spec( array $o, array $settings = array() ): array { $tax_map = array_merge( self::DEFAULT_TAX_MAP, (array) ( $settings['tax_map'] ?? array() ) ); $currency = strtoupper( (string) ( $o['currency'] ?? 'COP' ) ); $decimals = in_array( $currency, array( 'COP', 'JPY' ), true ) ? 0 : 2; $lines = array(); foreach ( (array) $o['lines'] as $ln ) { $qty = (float) $ln['quantity']; $total = (float) $ln['total']; $tax = (float) ( $ln['total_tax'] ?? 0 ); if ( $qty <= 0 ) { continue; } if ( 'product' === $ln['kind'] && abs( $total ) < 1e-9 && abs( $tax ) < 1e-9 && ! empty( $settings['skip_free_lines'] ) ) { continue; } $treat = self::treatment_for_class( (string) ( $ln['tax_class'] ?? '' ), $tax_map ); $t = self::TAX_TREATMENTS[ $treat ]; if ( null === $t ) { $pct = $total > 0 ? round( $tax / $total * 100, 2 ) : 0; $t = array( 'tax_code' => '01', 'tax_percent' => $pct, 'reason' => 0.0 === (float) $pct ? 'exento' : null ); } elseif ( $total > 0 && $tax > 0 ) { // The order's own tax lines are the truth when they disagree with // the class map (e.g. a product-level override). $native = round( $tax / $total * 100, 2 ); if ( abs( $native - (float) $t['tax_percent'] ) > 0.5 ) { $t['tax_percent'] = $native; } } elseif ( $total > 0 && $tax <= 0 && (float) $t['tax_percent'] > 0 ) { // Class says taxed, order says no tax collected (tax disabled / // exempt customer): invoice what was charged. $t = array( 'tax_code' => $t['tax_code'], 'tax_percent' => 0, 'reason' => $t['reason'] ?? 'excluido' ); } $unit = round( $total / $qty, $decimals + 4 ); $line = array( 'description' => trim( (string) ( $ln['name'] ?? '' ) ) !== '' ? (string) $ln['name'] : 'Artículo', 'quantity' => $qty, 'unit_price' => $unit, 'tax_code' => $t['tax_code'], 'tax_percent' => (float) $t['tax_percent'], 'is_service' => ! empty( $ln['is_service'] ), 'kind' => $ln['kind'], ); if ( ! empty( $ln['sku'] ) ) { $line['sku'] = (string) $ln['sku']; } if ( ! empty( $t['reason'] ) ) { $line['tax_exempt_reason'] = $t['reason']; } $sub = (float) ( $ln['subtotal'] ?? $total ); if ( $sub > $total + 1e-9 ) { $line['regular_price'] = round( $sub / $qty, $decimals + 4 ); } $lines[] = $line; } $b = (array) ( $o['billing'] ?? array() ); $country = strtoupper( (string) ( $b['country'] ?? 'CO' ) ); $country = '' !== $country ? $country : 'CO'; $company = trim( (string) ( $b['company'] ?? '' ) ); $doc_num = trim( (string) ( $o['doc_number'] ?? '' ) ); $doc_typ = self::doc_type_from( (string) ( $o['doc_type'] ?? '' ) ); if ( '' === $doc_typ && '' !== $doc_num ) { $doc_typ = '' !== $company ? '31' : (string) ( $settings['default_doc_type'] ?? '13' ); } $invoice_to = (string) ( $o['invoice_to'] ?? '' ); if ( ! in_array( $invoice_to, array( 'person', 'company', 'consumidor_final' ), true ) ) { $invoice_to = '31' === $doc_typ || ( '' !== $company && '' !== $doc_num ) ? 'company' : 'person'; } $buyer = array( 'invoice_to' => $invoice_to, 'first_name' => (string) ( $b['first_name'] ?? '' ), 'last_name' => (string) ( $b['last_name'] ?? '' ), 'email' => (string) ( $b['email'] ?? '' ), 'phone' => (string) ( $b['phone'] ?? '' ), 'country' => $country, ); if ( '' !== $company ) { $buyer['company_name'] = $company; } if ( '' !== $doc_typ ) { $buyer['doc_type'] = $doc_typ; } if ( '' !== $doc_num ) { $buyer['doc_number'] = $doc_num; } $addr = trim( implode( ' ', array_filter( array( $b['address_1'] ?? '', $b['address_2'] ?? '' ) ) ) ); if ( '' !== $addr ) { $buyer['address'] = $addr; } if ( ! empty( $b['city'] ) ) { $buyer['city'] = (string) $b['city']; } if ( ! empty( $b['postcode'] ) ) { $buyer['postal_code'] = (string) $b['postcode']; } if ( 'CO' === $country ) { $state = (string) ( $b['state'] ?? '' ); $dept = MiPlata_Geo::resolve_department( $state ); if ( $dept ) { $buyer['department'] = $dept; } $geo = MiPlata_Geo::resolve_city( (string) ( $o['city_code'] ?? '' ), (string) ( $b['city'] ?? '' ), $dept ? $dept : $state ); if ( $geo ) { $buyer['city_code'] = $geo['city_code']; $buyer['city'] = $geo['city_name']; } } $gateway = self::gateway_id( (string) ( $o['gateway'] ?? '' ) ); $spec = array( 'mode' => 'invoice', 'invoice_timing' => 'after_paid', 'currency' => $currency, 'amount' => round( (float) $o['total'], $decimals ), 'order_reference' => self::order_reference( $o ), 'gateway' => $gateway, 'paid' => ! empty( $o['is_paid'] ), 'buyer' => $buyer, 'lines' => $lines, ); if ( ! empty( $o['paid_at'] ) ) { $spec['paid_at'] = (string) $o['paid_at']; } if ( ! empty( $o['order_url'] ) && 0 === strpos( (string) $o['order_url'], 'https://' ) ) { $spec['order_url'] = (string) $o['order_url']; } if ( ! empty( $o['customer_note'] ) ) { $spec['buyer_note'] = mb_substr( (string) $o['customer_note'], 0, 500 ); } foreach ( array( 'delivery', 'on_paid' ) as $k ) { if ( ! empty( $settings[ $k ] ) ) { $spec[ $k ] = (string) $settings[ $k ]; } } if ( ! empty( $settings['invoice_type'] ) && '02' === $settings['invoice_type'] && 'CO' !== $country ) { $spec['invoice_type'] = '02'; } return $spec; } /** `1234` or the custom order number; always safe for the API pattern. */ public static function order_reference( array $o ): string { $n = trim( (string) ( $o['number'] ?? $o['id'] ?? '' ) ); $n = preg_replace( '/[^\w.\/#-]/', '-', $n ); return '' === $n ? (string) ( $o['id'] ?? '0' ) : substr( $n, 0, 80 ); } /** Sum of lines the way the edge does it (integer rounding per line). */ public static function totals( array $lines, string $currency ): array { $d = in_array( strtoupper( $currency ), array( 'COP', 'JPY' ), true ) ? 0 : 2; $f = 10 ** $d; $sub = 0.0; $tax = 0.0; foreach ( $lines as $ln ) { $ext = round( (float) $ln['quantity'] * (float) $ln['unit_price'] * $f ) / $f; $t = round( $ext * (float) ( $ln['tax_percent'] ?? 0 ) ) / 100; $sub += $ext; $tax += round( $t * $f ) / $f; } $sub = round( $sub * $f ) / $f; $tax = round( $tax * $f ) / $f; return array( 'subtotal' => $sub, 'tax' => $tax, 'total' => round( ( $sub + $tax ) * $f ) / $f, ); } }