Clay-UI

 view release on metacpan or  search on metacpan

examples/16-invoice-pdf.pl  view on Meta::CPAN

		my $panel = $self->_box(
			layout => {
				sizing    => { width => sizing_grow() },
				padding   => padding_all(12),
				child_gap => 24,
			},
			background_color => $COLOR{zebra},
			corner_radius    => 4,
		);
		my $terms = $self->_box(layout => { sizing => { width => sizing_grow() }, layout_direction => CLAY_TOP_TO_BOTTOM, child_gap => 4 });
		$terms->add_child(
			$self->_caption('Payment terms'),
			map { $self->_wrapped_text($_) } $invoice->{payment_terms}->@*,
		);
		my $bank = $self->_column(4,
			$self->_caption('Bank details'),
			map { $self->_text($_) } $invoice->{bank}->@*,
		);
		$panel->add_child($terms, $bank);
		return $panel;
	}

	# ---- Footer ----

	method _footer ($page_number, $page_count) {
		my $footer = $self->_box(
			layout => {
				sizing          => { width => sizing_grow() },
				padding         => { top => 8 },
				child_alignment => { y => CLAY_ALIGN_Y_CENTER },
			},
			border_color => $COLOR{rule},
			border_width => { top => 1, left => 0, right => 0, bottom => 0, between_children => 0 },
		);
		$footer->add_child(
			$self->_text($invoice->{seller}{legal}, font_size => 7, color => 'muted'),
			$self->_spacer,
			$self->_text("Page $page_number / $page_count", font_size => 8, color => 'muted'),
		);
		return $footer;
	}
}

# ---- Invoice data ----

sub format_money ($cents) {
	my $text = sprintf '%d.%02d', int($cents / 100), $cents % 100;
	1 while $text =~ s/^(\d+)(\d{3})/$1,$2/;
	return $text;
}

# Deterministic line items for the service period, September 2026
# (calendar weeks 36 to 40). Monthly services come first, billed once
# for the month; the other services are billed per week. Within a week
# the services rotate, a week has at most one item of each daily service,
# and every hourly item names its own ticket, so no week lists the same
# item twice. Some items carry a long detail text
# that wraps over several lines.
sub generate_items ($count) {
	my @monthly_services = (
		[ 'Managed database', 24_900, 'PostgreSQL cluster with daily backups, point-in-time recovery and around-the-clock monitoring.' ],
		[ 'Support retainer', 45_000, undef ],
	);
	my @weekly_services = (
		[ 'Backend development',  'hours',   9_500, 'API endpoints for the order import, including validation of partner payloads and retry handling for failed deliveries.' ],
		[ 'Frontend development', 'hours',   8_800, undef ],
		[ 'Code review',          'hours',   7_500, 'Review of pull requests for the reporting module.' ],
		[ 'Project management',   'hours',   7_000, undef ],
		[ 'UX workshop',          'days',  120_000, 'Remote workshop with stakeholders, including preparation, moderation and a written summary of all decisions taken.' ],
		[ 'Security audit',       'days',  135_000, 'Dependency audit and penetration test of the customer portal; findings delivered as a prioritised report.' ],
	);
	my ($first_week, $week_count) = (36, 5);

	my @hourly_services = grep { $_->[1] eq 'hours' } @weekly_services;
	my $monthly_count   = $count < @monthly_services ? $count : @monthly_services;

	my @items;
	for my $service (@monthly_services[ 0 .. $monthly_count - 1 ]) {
		my ($name, $unit_price, $detail) = @$service;
		push @items, {
			title      => "$name, September 2026",
			detail     => $detail,
			quantity   => 1,
			unit       => 'month',
			unit_price => $unit_price,
			amount     => $unit_price,
		};
	}

	my $weekly_count = $count - @items;
	my %items_in_week;
	my $next_ticket = 1040;
	for my $index (0 .. $weekly_count - 1) {
		my $week     = $first_week + int($index * $week_count / $weekly_count);
		my $position = $items_in_week{$week}++;
		# Once every service had its turn this week, only hourly ones repeat.
		my $services = $position < @weekly_services ? \@weekly_services : \@hourly_services;
		my ($name, $unit, $unit_price, $detail) = $services->[ ($week + $position) % @$services ]->@*;
		my $quantity = $unit eq 'hours' ? 1 + ($index * 7) % 16 : 1 + $index % 3;
		my $ticket   = $unit eq 'hours' ? sprintf(', ticket NL-%d', $next_ticket++) : '';
		push @items, {
			title      => "$name, calendar week $week$ticket",
			detail     => $detail,
			quantity   => $quantity,
			unit       => $quantity == 1 ? substr($unit, 0, -1) : $unit,
			unit_price => $unit_price,
			amount     => $quantity * $unit_price,
		};
	}
	return \@items;
}

sub build_invoice ($item_count) {
	my $items       = generate_items($item_count);
	my $vat_percent = 19;
	my $subtotal    = 0;
	$subtotal += $_->{amount} for @$items;
	my $vat = int(($subtotal * $vat_percent + 50) / 100);    # cents, rounded half up

	return {
		number   => 'INV-2026-0042',



( run in 1.112 second using v1.01-cache-2.11-cpan-3f962061493 )