Skip to content
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ are updated a few times per year. The process differs by fund:
| TUV100 (III Samba Pensionifond) | Hardcoded URL in PHP template | git push → CircleCI |
| TKF100 (Täiendav Kogumisfond) | ACF field in WordPress DB | WP REST API call |

Monthly **investment reports** are separate from the legal documents above and work the same way for all
four funds: the `investment_report_file` ACF field on the fund page, set from wp-admin or over the REST API
(`POST /wp-json/wp/v2/pages/{id}` with `{"acf": {"investment_report_file": <attachment id>}}`). The three
pension fund templates fall back to a hardcoded URL while the field is unset. The field groups that carry it
declare `'show_in_rest' => 1` — without that ACF drops the `acf` key from the request and the write no-ops.

### Required credentials (environment variables — never hardcode)

```
Expand Down Expand Up @@ -90,7 +96,7 @@ CircleCI build status: https://app.circleci.com

### Step 4 — Update savings fund ACF fields (TKF100)

ACF REST API is **not enabled** on this site, so this step must be done manually in the WordPress admin:
Do this in the WordPress admin:

1. Go to `https://tuleva.ee/wp-admin/post.php?post=35292&action=edit`
2. Scroll to the ACF document fields
Expand Down
55 changes: 55 additions & 0 deletions src/wp-content/themes/tuleva/helpers/acf/fund-pension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php
if( function_exists('acf_add_local_field_group') ):

acf_add_local_field_group(array (
'key' => 'group_fund_pension_investment_report',
'title' => 'Investment Report',
'fields' => array (
array (
'key' => 'field_fund_pension_investment_report',
'label' => 'Investment Report',
'name' => 'investment_report_file',
'type' => 'file',
'instructions' => 'Select the latest investment report PDF from media library. When set, this replaces the hardcoded report URL in the template.',
'required' => 0,
'return_format' => 'url',
'library' => 'all',
'mime_types' => 'pdf',
),
),
'location' => array (
array (
array (
'param' => 'page_template',
'operator' => '==',
'value' => 'page_fund-stocks.php',
),
),
array (
array (
'param' => 'page_template',
'operator' => '==',
'value' => 'page_fund-bonds.php',
),
),
array (
array (
'param' => 'page_template',
'operator' => '==',
'value' => 'page_fund-third.php',
),
),
),
'menu_order' => 10,
'position' => 'normal',
'style' => 'default',
'label_placement' => 'top',
'instruction_placement' => 'label',
'active' => 1,
// Required for onboarding-service to set this field over the REST API
// (POST /wp-json/wp/v2/pages/{id} with {"acf": {"investment_report_file": <attachment id>}}).
// Without it ACF strips the "acf" key from the request and the write silently no-ops.
'show_in_rest' => 1,
));

endif;
4 changes: 4 additions & 0 deletions src/wp-content/themes/tuleva/helpers/acf/fund-savings.php
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@
'label_placement' => 'top',
'instruction_placement' => 'label',
'active' => 1,
// Required for onboarding-service to set investment_report_file over the REST API
// (POST /wp-json/wp/v2/pages/{id} with {"acf": {...}}). Without it ACF strips the
// "acf" key from the request and the write silently no-ops.
'show_in_rest' => 1,
));

endif;
1 change: 1 addition & 0 deletions src/wp-content/themes/tuleva/helpers/acf/init.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
'helpers/acf/membership.php', // Membership page
'helpers/acf/fund-other.php', // Other fund pages
'helpers/acf/fund-savings.php', // Savings fund page
'helpers/acf/fund-pension.php', // Pension fund investment report field (TUK75, TUK00, TUV100)
'helpers/acf/fund-choosing.php', // Instructions — Step 1 page fields
'helpers/acf/jobs.php' // Jobs page fields
];
Expand Down
59 changes: 36 additions & 23 deletions src/wp-content/themes/tuleva/helpers/extras.php
Original file line number Diff line number Diff line change
Expand Up @@ -390,37 +390,50 @@ function hyphenate_string($string) {
}

function generate_report_link($url, $link_text = null) {
preg_match('/\/(\d{4})\/(\d{2})\//', $url, $matches);
$year = intval($matches[1]);
$month = intval($matches[2]);

$month--;
if ($month === 0) {
$month = 12;
$year--;
}
$is_absolute = (bool) filter_var($url, FILTER_VALIDATE_URL);
// parse_url() returns null for a URL with no path at all ("https://tuleva.ee"), which
// basename() rejects as of PHP 8.1.
$path = ($is_absolute ? parse_url($url, PHP_URL_PATH) : $url) ?? '';
$filename = basename($path);

$year_str = strval($year);
$month_str = str_pad(strval($month), 2, '0', STR_PAD_LEFT);
if (preg_match('/(\d{4})-(\d{2})/', $filename, $matches)) {
// Report period is encoded directly in the filename (YYYY-MM), e.g.
// "... investeeringute aruanne 2026-05.pdf" — use it as the label.
$date_text = sprintf('%s.%s', $matches[2], $matches[1]);
} elseif (preg_match('/\/(\d{4})\/(\d{2})\//', $url, $matches)) {
// Legacy uploads carry no period in the filename: the report covers the month before the
// upload-folder month (a report is uploaded the month after the period it covers).
$year = intval($matches[1]);
$month = intval($matches[2]);

$month--;
if ($month === 0) {
$month = 12;
$year--;
}

$default_text = sprintf('%s.%s', $month_str, $year_str);
if ($link_text !== null) {
$link_text = sprintf('%s (%s)', $link_text, $default_text);
$month_str = str_pad(strval($month), 2, '0', STR_PAD_LEFT);
$date_text = sprintf('%s.%s', $month_str, strval($year));
} else {
$link_text = $default_text;
$date_text = null;
}

if (filter_var($url, FILTER_VALIDATE_URL)) {
$path = parse_url($url, PHP_URL_PATH);
} else {
$path = $url;
if ($date_text !== null) {
$link_text = $link_text !== null
? sprintf('%s (%s)', $link_text, $date_text)
: $date_text;
} elseif ($link_text === null) {
$link_text = __('Investment reports', TEXT_DOMAIN);
}

// Preserve the original host for absolute URLs: the ACF-supplied media URL may be served from a
// CDN or dedicated media host. Only site-relative paths are resolved against the current site.
$href = $is_absolute ? $url : get_site_url() . $url;

$output = sprintf(
'<a href="%s%s" target="_blank">%s</a>',
get_site_url(),
$path,
$link_text
'<a href="%s" target="_blank">%s</a>',
esc_url($href),
esc_html($link_text)
);

return $output;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,10 @@
<h2 class="mt-5 mb-4 h4"><?php _e('Reports', TEXT_DOMAIN) ?></h2>
<ul class="list-style-arrow text-secondary">
<li>
<?php echo generate_report_link('https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-Maailma-Volakirjade-Pensionifondi-investeeringute-aruanne-2026-07.pdf',__('Investment reports', TEXT_DOMAIN)); ?><?php _e(' (in Estonian)', TEXT_DOMAIN) ?>
<?php
$report_url = get_field('investment_report_file') ?: 'https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-Maailma-Volakirjade-Pensionifondi-investeeringute-aruanne-2026-07.pdf';
echo generate_report_link($report_url, __('Investment reports', TEXT_DOMAIN));
?><?php _e(' (in Estonian)', TEXT_DOMAIN) ?>
<br>
<a href="https://www.pensionikeskus.ee/ii-sammas/kohustuslikud-pensionifondid/fid/76/" target="_blank"><?php _e('Previous reports', TEXT_DOMAIN) ?></a>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@
<h2 class="mt-5 mb-4 h4"><?php _e('Reports', TEXT_DOMAIN) ?></h2>
<ul class="list-style-arrow mb-5 text-secondary">
<li>
<?php echo generate_report_link('https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-Maailma-Aktsiate-Pensionifondi-investeeringute-aruanne-2026-07.pdf',__('Investment reports', TEXT_DOMAIN)); ?><?php _e(' (in Estonian)', TEXT_DOMAIN) ?>
<?php
$report_url = get_field('investment_report_file') ?: 'https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-Maailma-Aktsiate-Pensionifondi-investeeringute-aruanne-2026-07.pdf';
echo generate_report_link($report_url, __('Investment reports', TEXT_DOMAIN));
?><?php _e(' (in Estonian)', TEXT_DOMAIN) ?>
<br>
<a href="https://www.pensionikeskus.ee/ii-sammas/kohustuslikud-pensionifondid/fid/77/" target="_blank"><?php _e('Previous reports', TEXT_DOMAIN) ?></a>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@
<h2 class="mt-5 mb-4 h4"><?php _e('Reports', TEXT_DOMAIN) ?></h2>
<ul class="list-style-arrow text-secondary">
<li>
<?php echo generate_report_link('https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-III-Samba-Pensionifondi-investeeringute-aruanne-2026-07.pdf',__('Investment reports', TEXT_DOMAIN)); ?><?php _e(' (in Estonian)', TEXT_DOMAIN) ?>
<?php
$report_url = get_field('investment_report_file') ?: 'https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-III-Samba-Pensionifondi-investeeringute-aruanne-2026-07.pdf';
echo generate_report_link($report_url, __('Investment reports', TEXT_DOMAIN));
?><?php _e(' (in Estonian)', TEXT_DOMAIN) ?>
<br>
<a href="https://www.pensionikeskus.ee/iii-sammas/vabatahtlikud-fondid/fid/81/" target="_blank"><?php _e('Previous reports', TEXT_DOMAIN) ?></a>
</li>
Expand Down
171 changes: 171 additions & 0 deletions src/wp-content/themes/tuleva/tests/helpers/ReportLinkTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;

if (!defined('TEXT_DOMAIN')) {
define('TEXT_DOMAIN', 'tuleva');
}

/**
* extras.php registers hooks and shortcodes at include time and calls a handful of
* WordPress escaping/URL helpers. None of those exist outside WordPress, so stand
* them in here.
*/
if (!function_exists('add_filter')) {
function add_filter($hook, $callback, $priority = 10, $args = 1)
{
}
}

if (!function_exists('add_action')) {
function add_action($hook, $callback, $priority = 10, $args = 1)
{
}
}

if (!function_exists('add_shortcode')) {
function add_shortcode($tag, $callback)
{
}
}

if (!function_exists('get_site_url')) {
function get_site_url()
{
return 'https://tuleva.ee';
}
}

if (!function_exists('__')) {
function __($text, $domain = null)
{
return $text;
}
}

if (!function_exists('esc_url')) {
function esc_url($url)
{
return htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
}
}

if (!function_exists('esc_html')) {
function esc_html($text)
{
return htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
}
}

require_once __DIR__ . '/../../helpers/extras.php';

final class ReportLinkTest extends TestCase
{
#[Test]
#[DataProvider('reportPeriodProvider')]
public function labelCarriesTheReportPeriod(string $url, string $expectedPeriod): void
{
$link = generate_report_link($url, 'Investment reports');

$this->assertStringContainsString("Investment reports ($expectedPeriod)", $link);
}

public static function reportPeriodProvider(): array
{
return [
'period in filename wins over upload folder' => [
'https://tuleva.ee/wp-content/uploads/2026/08/Tuleva-Maailma-Aktsiate-Pensionifondi-investeeringute-aruanne-2026-07.pdf',
'07.2026',
],
'no period in filename falls back to the month before the upload folder' => [
'https://tuleva.ee/wp-content/uploads/2026/08/investeeringute-aruanne.pdf',
'07.2026',
],
'upload folder January rolls back to the previous December' => [
'https://tuleva.ee/wp-content/uploads/2026/01/investeeringute-aruanne.pdf',
'12.2025',
],
];
}

#[Test]
public function absoluteUrlKeepsItsOwnHost(): void
{
$link = generate_report_link(
'https://media.example.com/wp-content/uploads/2026/08/aruanne-2026-07.pdf',
'Investment reports'
);

$this->assertStringContainsString(
'href="https://media.example.com/wp-content/uploads/2026/08/aruanne-2026-07.pdf"',
$link
);
}

#[Test]
public function relativePathIsResolvedAgainstTheSiteUrl(): void
{
$link = generate_report_link('/wp-content/uploads/2026/08/aruanne-2026-07.pdf', 'Investment reports');

$this->assertStringContainsString(
'href="https://tuleva.ee/wp-content/uploads/2026/08/aruanne-2026-07.pdf"',
$link
);
}

#[Test]
public function urlWithoutAnyPeriodFallsBackToThePlainLabel(): void
{
$link = generate_report_link('https://tuleva.ee/files/aruanne.pdf', 'Investment reports');

$this->assertStringContainsString('>Investment reports<', $link);
}

#[Test]
public function urlWithoutAPeriodOrALabelStillGetsATranslatableLabel(): void
{
$link = generate_report_link('https://tuleva.ee/files/aruanne.pdf');

$this->assertStringContainsString('>Investment reports<', $link);
}

#[Test]
public function urlWithNoPathAtAllDoesNotWarn(): void
{
$link = generate_report_link('https://tuleva.ee', 'Investment reports');

$this->assertStringContainsString('href="https://tuleva.ee"', $link);
$this->assertStringContainsString('>Investment reports<', $link);
}

#[Test]
public function labelAndUrlAreEscaped(): void
{
$link = generate_report_link(
'https://tuleva.ee/files/aruanne.pdf?a=1&b=2',
'Reports <script>alert(1)</script>'
);

// Assert what escaping has to achieve, not how a given implementation spells it. esc_url()
// is stubbed here with htmlspecialchars(), which writes & as &amp;, while real WordPress
// writes &#038;. Pinning either spelling tests the stub, not the behaviour.
$this->assertStringNotContainsString('<script>', $link);
$this->assertDoesNotMatchRegularExpression('/href="[^"]*&(?!amp;|#0?3[48];)/', $link);
}

#[Test]
public function noPayloadQuoteEscapesItsAttribute(): void
{
// The one assertion that fails if esc_url()/esc_html() stop being called at all, whichever
// encoding they use: a double quote reaching the output raw would close href="..." or
// target="..." early. The markup has exactly two quoted attributes, so four delimiters is
// the whole budget -- a fifth quote is a payload that escaped its attribute.
$link = generate_report_link('https://tuleva.ee/files/a".pdf', 'Reports " onmouseover=x');

$this->assertSame(4, substr_count($link, '"'), 'only the attribute delimiters may survive');
$this->assertStringNotContainsString('onmouseover=x"', $link);
}
}