Compare commits

..

62 Commits
v1.5 ... master

Author SHA1 Message Date
jordi fita mas 1fdbf56aa7 Translate the day name in template.dayofWeek 2025-01-29 12:10:12 +01:00
jordi fita mas 79eee41365 Fix update of weather_forecast
The real problem was that i was trying to update the forecast via
pgx.Pool, not the acquired connection, therefore it did not have the
correct role.

I moved everything to a different function where db is not visible in
scope, just to make sure i did not fuck up anywhere else.
2025-01-29 12:07:29 +01:00
jordi fita mas 9b938dad97 Switch to admin role first thing in camper-weather
The authenticator user has no access to the weather_forecast relation.
2025-01-29 11:19:08 +01:00
jordi fita mas 5ccd796e1b Add camper-weather to Debian’s camper.install 2025-01-29 10:53:42 +01:00
jordi fita mas 9189d42630 Add schedule for camper-weather in Debian’s cron 2025-01-29 10:50:15 +01:00
jordi fita mas 8aeb4a1759 Reduce vertical spacing of weather widget 2025-01-29 10:44:47 +01:00
jordi fita mas c7ffa15db1 Update translations 2025-01-28 21:40:20 +01:00
jordi fita mas d64e899e0f Add a weather forecast
Requested by customer. Needs a command run in a cron to update the
forecast data from an URL, and only a single URL is supported by now.
2025-01-28 21:37:30 +01:00
jordi fita mas 7c6bac1986 Add season dates for “next year”
This is to test the booking form’s behavior when there is a gap between
bookable dates, especially around New Year’s.
2024-11-20 19:43:59 +01:00
jordi fita mas 5b89c97b00 Add operating_dates to campsite type table
We forgot that different accommodation types are not always operating on
the whole season calendar, thus we need a specific date for each type.

Someday i will add the field in the administration panel, but for now i
will have to add them by hand, as people are starting to book plots on
dates that are not operating.
2024-07-15 23:41:47 +02:00
jordi fita mas d8524c347e Fix French typo 2024-07-15 23:12:12 +02:00
jordi fita mas c54e147173 Change “ACSI” to “ACSI / ANWB”
Apparently, ANWB is a camping card similar to ACSI from the Netherlands,
and both cards have the exact same discounts.
2024-05-13 10:40:21 +02:00
jordi fita mas 92c0cb4de0 Add filters and pagination to login attempts 2024-05-03 20:45:14 +02:00
jordi fita mas b4ccdeff2f Add filters and pagination to payments 2024-05-03 20:09:07 +02:00
jordi fita mas 48c1529e6c Add pagination to invoices
I’ve removed the total amount because it is very difficult to get it
with pagination, and customer never saw it (it was from Numerus), thus
they won’t miss it—i hope.
2024-05-03 19:13:49 +02:00
jordi fita mas 674cdff87b Add a new Cursor form type
To hold the common logic of detecting pagination, forming the key, and
splitting its values later on.

I can take advantage that a form with action="get" already adds its
fields to the query string to have a common template for pagination. The
only problem is that i have different column spans for different tables,
therefore had to add a colspan to the struct.
2024-05-03 19:00:02 +02:00
jordi fita mas 3a7d454826 Add filtering and pagination to customer section 2024-05-03 18:10:16 +02:00
jordi fita mas 50548c29ab Move booking filter form struct into a separate file 2024-05-03 18:09:55 +02:00
jordi fita mas e5253f9adb Allow to cancel bookings 2024-05-03 17:21:20 +02:00
oriol carbonell pujolàs e425b88477 Fix spacing of booking forms 2024-05-03 12:55:43 +02:00
jordi fita mas c84b58a0d5 Add the section of prebookings
It is just the index of bookings in the created state, but we thought it
would make easier to understand the difference between a booking from
a customer not yet confirmed, from a booking confirmed or created by the
staff.
2024-05-03 01:01:01 +02:00
jordi fita mas 5d4fe15e88 Add filtering and pagination for bookings 2024-05-03 00:28:48 +02:00
jordi fita mas b2ee4dfea3 Add marshal_payment and unmarshal_booking functions
The idea is that we will marshal the payment, send it to the campsite’s
instance by email, and then unmarshal it as a booking, that way we can
have a one way replication from the internal to the public instance with
a way back to send the payments.

For testing purposes, i just create the booking in the same instance.

Had to change the booking relation’s permissions to allow insert from
a guest, much like for payments, because the notification from Redsys
comes as a guest connection.  I need this even with all the
marshal/unmarshal shenanigans because not everyone will have an internal
instance, thus need to allow bookings from guest connections.
2024-04-29 20:59:22 +02:00
jordi fita mas 7edf3a3ed1 Pre-fill all guests with the holder’s address
Most will be families living at the same address.  And, if they are not,
it is far easier to replace the incorrect address with the actual,
rather than write the same address to all family members under the same
household.
2024-04-29 17:49:38 +02:00
jordi fita mas f71ad2cc65 Redirect to invoice after create, not booking 2024-04-28 22:50:56 +02:00
jordi fita mas 90d8247c0f Fix null problems 2024-04-28 22:49:04 +02:00
jordi fita mas 2299f2325e Allow to create the customer to invoice “in flight”
That way i can get the data from the booking or the actual customer.
2024-04-28 22:36:21 +02:00
jordi fita mas ff9f33dfba Add invoicing of bookings
It is a bit user-hostile because you have to create a new customer prior
to create the invoice, but that’s what it is for now.
2024-04-28 21:56:51 +02:00
jordi fita mas 205d1f1e99 Include weasyprint as dependency of camper package 2024-04-28 21:47:44 +02:00
jordi fita mas d0f6c9734a Confirm a created booking if admin saves it 2024-04-28 21:00:15 +02:00
jordi fita mas 17f7520876 Add customer and invoices sections
Copied as much as i could from Numerus, and made as few modifications as
i could to adapt to this code base; it is, quite frankly, a piece of
shit.

We need to be able to create invoices from scratch “just in case”,
apparently, but it is not yet possible to create an invoice from a
booking.
2024-04-28 20:28:45 +02:00
jordi fita mas 3559ff311b Chose the actual campsite when double-clicking on the accommodation grid 2024-04-26 17:31:39 +02:00
jordi fita mas 846ca0bc5c Add an empty value to the “choose country” option in booking form
Otherwise, the value is the same as the label
2024-04-26 17:31:13 +02:00
jordi fita mas f2b24a83a3 Add check-in form
I based the form from the documentation given by the Mossos
d’Esquadra[0], required by law.

https://registreviatgers.mossos.gencat.cat/mossos_hotels/AppJava/fitxaviatger.do?reqCode=create
2024-04-26 17:09:36 +02:00
jordi fita mas c9e8165f83 Allow updating bookings
I need to retrieve the values from the database and put them in the
form, like all other forms, but in this case the processing is done as
if it were a new form, because everything comes from the query string
and there is no need to do any extra work then.

Had to move the <footer> from the fields.gohtml to form.gohtml because
then it could not know that it was editing an existing booking.  Had to
move the <fieldset> out too, in order to give it an ID and make it
htmx’s target, or it would replace the form, causing even more problems
—the button would disappear then—.  The target **must** be in <form>
because it is needed for tis children’s hx-get and for its own hx-put.
2024-04-25 20:27:08 +02:00
jordi fita mas 30e87c309e Remove useless fields from paymentDraft
At first i thought i would need to keep the second query of draftPayment
in newBookingCart, its caller, and i added these fields to pass back
the parsed or retrieved values, but when i could move that query within
draftPayment i forgot to remove them.
2024-04-25 13:17:07 +02:00
jordi fita mas 9eb6483cb9 Allow opening a booking or creating a new from the booking grid
I wanted to use a regular <a>, but apparently rendering that many
anchors is too resource-intensive for Firefox, and it is noticeably
slower.  It was even worse, in fact, because i had to have different
content for the main grid and the grid show in the new booking form,
as i did not want to have these links there, and had call a template for
each cell: 3 months × ~30 days × ~100 campsites = 9000 calls!

Using JavaScript for that is shameful, but it does not add much to the
existing markup, and no need for template fuckery.

I am using double-click to follow these links, instead of single click,
because it would be too easy to misclik on the grid, but that forced me
to add `user-select: none` to prevent the selection of text when double-
clicking.
2024-04-25 12:29:43 +02:00
jordi fita mas 2d4055b653 Fix insert of booking in demo SQL file 2024-04-24 20:39:19 +02:00
jordi fita mas b7e7d79177 Add alter column back to integer for booking.number_dogs in reverse 2024-04-24 20:29:23 +02:00
jordi fita mas fdf9502c8b “Finish” the new booking form
Had to bring the same fields that i have for a payment to booking,
except that some of those should be nullable, because it is unreasonable
to ask front desk to gather all customer data when they have a booking
via phone, for instance.

Therefore, i can not take advantage of the validation for customer data
that i use in the public-facing form, but, fortunately, most of the
validations where in separated functions, thus only had to rewrite that
one for this case.

I already have to create a booking from a payment, when receiving a
payment from the public instance, thus i made that function and reused
it here.  Then i “overwrite” the newly created pre-booking with the
customer data from the form, and set is as confirmed, as we do not see
any point of allowing pre-bookings from employees.
2024-04-24 20:19:13 +02:00
jordi fita mas 3aa53cf1a9 “Mockup” for the new booking form
It does nothing but compute the total of a booking, much like it does
for guests.  In fact, i use the same payment relations to do the exact
same computation, otherwise i am afraid i will make a mistake in the
ACSI or such, now or in future version; better if both are exactly the
same.

The idea is that once the user creates the booking, i will delete that
payment, because it makes no sense to keep it in this case; nobody is
going to pay for it.

Had to reuse the grid showing the bookings of campsites because
employees need to select one or more campsites to book, and need to see
which are available.  In this case, i have to filter by campsite type
and use the arrival and departure dates to filter the months, now up to
the day, not just month.

Had to change max width of th and td in the grid to take into account
that now a month could have a single day, for instance, and the month
heading can not stretch the day or booking spans would not be in their
correct positions.

For that, i needed to access campsiteEntry, bookingEntry, and Month from
campsite package, but campsite imports campsite/types, and
campsite/types already imports booking for the BookingDates type.  To
break the cycle, had to move all that to booking and use from campsite;
it is mostly unchanged, except for the granularity of dates up to days
instead of just months.

The design of this form calls for a different way of showing the totals,
because here employees have to see the amount next to the input with
the units, instead of having a footer with the table.  I did not like
the idea of having to query the database for that, therefore i “lifter”
the payment draft into a struct that both public and admin forms use
to show they respective views of the cart.
2024-04-23 21:07:41 +02:00
jordi fita mas 598354e8b7 Add missing autocomplete attribute to town or village 2024-04-22 18:44:48 +02:00
jordi fita mas dab19bbc4d Fix attributes of country select in public booking page 2024-04-22 18:44:22 +02:00
jordi fita mas 0e22096447 Tag version 8 of the database
I will need to change draft_payment **again**, and possibly many times,
during the development of the booking admin section.  Many times.
2024-04-22 13:43:38 +02:00
jordi fita mas a26e9c6e12 Fix tests for booking and booking_campsite 2024-04-21 21:57:34 +02:00
jordi fita mas 7eb718dfd9 Allow many campsites for each reservation
This is actually only used for plots, but, of course, it means that
every booking now can potentially have many booked campsites, and have
to create a relation for it.

I now have a conundrum regarding stay dates: i need them to be in the
same table as the campsite_id, because constraints only work on a single
relation and without the dates i can not make sure that i am not
overbooking a given campsite; but, on the other hand, all campsites
under the same booking must be for the same dates.

Where does stay belong, then? In booking or booking_campsite? If in
booking then i can not have a constraint that most assuredly will bite
me in the back, but if in booking_campsite then each campsite could
potentially have different dates.

As far as i can see, i can not use a exclude constraint with <> for
dates in booking_campsite to ensure that all rows with the same
booking_id have the same stay (i.e., exclude those that have a different
stay for the same booking_id).

For now, the say is in **both** relations: in booking, because i need it
when it is a prebooking, at least, and in booking_campsite for the
aforementioned constraint requirements.

Will this come back and bite me? Yes, it will. But what can i do?
2024-04-21 21:28:41 +02:00
jordi fita mas cba892c4c0 Fix booking list to use stay instead of arrival_date and departure_date 2024-04-19 21:29:36 +02:00
jordi fita mas cdd91c815e Show booking on booking grid
I need the campsite_id in booking to know what row to show the booking
at. Besides the need of knowing which actual campsite has been booked,
of course.

This field is nullable because we can not now it until an employee has
confirmed the booking; until that point we only know the campsite type
customer requested.  I do not care much if the campsite_id is from a
different campsite_type, because maybe the customer requested the change
by phone or what have you, therefore the database can not be that
strict.  It must have a value if the booking is confirmed.

It helps me if the arrival_date and departure_date is a single
daterange, because then i can use `&&` and other range operators to work
with these dates.  For instance, i have to intersect it with the range
displayed on the screen in order to know which day i have to put it.
But then i have to know whether the booking begins and ends in the
display range, because i only have to show arrival and departure (i.e.,
the box half-way within the first or last boxes) on these days only.
2024-04-19 21:09:28 +02:00
jordi fita mas bc5fd61d5d Move the booking’s filter to the bottom
The first thing to show, the most important, is the grid; then users
can change the grid’s output.
2024-04-19 17:39:44 +02:00
jordi fita mas 23d16fa162 Only show individual campsite and “Add campsite” links to admin
Regular users do not need, or can use, those links.
2024-04-19 17:37:28 +02:00
jordi fita mas e726bde025 Replace admin’s campsite map with a booking grid
Customer told us that they are used to a view of the booking status of
each campsite in the form of a grid: each campsite is a row, and each
day a column; bookings are show as boxes from the first day to the last
day on the corresponding campsite’s row.

I do not yet show the booking boxes, but at least now i have the grid
and date selector form in place.

In the form i would need a couple of input[type=month], but this is not
yet supported in Firefox and Safari. According to MDN, one common way
to bypass that problem is to have two fields, one for the month and the
other for the year; i just did that, but had to create a new input type
in the `form` package just for this.
2024-04-19 11:29:52 +02:00
jordi fita mas 746aa013d3 Group ACSI options and allow for usage in different groups
It turns out that, **this time**, at least, the way to compute the
discount is not by “the more expensive”, but “the more expensive _in a
given group_”.

However, there are a couple of options, such as motorhome, that can be
in different groups but only must be used once.
2024-04-03 12:56:52 +02:00
jordi fita mas b291ac34fc Tag database with version 7 2024-04-03 09:34:36 +02:00
jordi fita mas 6c18529317 Take only into account the more expensive option when computing ACSI
Apparently, this is how it is done, now.
2024-03-25 17:47:43 +01:00
jordi fita mas 75c94a95f5 Remove a paste error from camper.css 2024-03-25 16:43:58 +01:00
jordi fita mas 72f8a329d2 Use pre-authorization to accept payment, rather than charge
Customer wants this because the booking is not automatically created,
thus it is possible to overbook.  They want to accept the payment of
those that they can actually book.
2024-03-24 22:06:59 +01:00
jordi fita mas d71d974abd Change demo’s email address
Since this address is used to send notifications when a new payment is
received, is quite important not to use the same address as in
production, or there is a non-trivial chance of confusion.
2024-03-24 20:28:48 +01:00
jordi fita mas 1b46f4224e Compute the subtotal of dogs using their number, not as a boolean
I swear i believe sometime before we said that the number of dogs is not
important and should be used only as a boolean, but apparently it is
wrong: it should be number_dogs * cost_per_pet.
2024-03-20 18:17:58 +01:00
jordi fita mas fc9ac321d5 Tag database with version 6 2024-03-20 18:10:49 +01:00
jordi fita mas dbbd06cf85 Change the verify for season_calendar_season_id_fkey
It failed in build.opensuse.org; possibly the view in information_scheme
is different.
2024-03-14 22:28:46 +01:00
jordi fita mas 0412ffca05 Compute ACSI discount
After months of keeping what does the ACSI checkbox mean, now customer
told us that we should add a discount based on a series of
arbitrary conditions that, and need to be done NOW!

There is no UI to edit the conditions due to lack of time.
2024-03-14 22:08:01 +01:00
jordi fita mas cc1b334639 Add missing foreign key between season_calendar and season 2024-03-14 18:38:58 +01:00
346 changed files with 19973 additions and 1851 deletions

View File

@ -0,0 +1,92 @@
package main
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/database"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"time"
)
type Temperature struct {
Day float64 `json:"day"`
Min float64 `json:"min"`
}
type Weather struct {
ID int `json:"id"`
}
type Forecast struct {
Timestamp int64 `json:"dt"`
Weather []Weather `json:"weather"`
Temperature Temperature `json:"temp"`
}
type OpenWeatherMap struct {
Forecasts []Forecast `json:"list"`
}
func main() {
db, err := database.New(context.Background(), os.Getenv("CAMPER_DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
conn, err := db.Acquire(context.Background())
if err != nil {
log.Fatal(err)
}
defer conn.Release()
updateForecast(context.Background(), conn)
}
func updateForecast(ctx context.Context, conn *database.Conn) {
if _, err := conn.Exec(ctx, "set role to admin"); err != nil {
log.Fatal(err)
}
var stationURL string
if err := conn.QueryRow(ctx, `
select station_uri
from weather_forecast
`).Scan(
&stationURL,
); err != nil {
log.Fatal(err)
}
resp, err := http.Get(stationURL)
if err != nil {
log.Fatal(err)
}
var result OpenWeatherMap
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
log.Fatal(err)
}
for _, forecast := range result.Forecasts {
if _, err := conn.Exec(ctx, `
update weather_forecast
set weather_condition_id = $1
, day_temperature = $2
, min_temperature = $3
, forecasted_at = $4
, updated_at = current_timestamp
where station_uri = $5
`,
strconv.Itoa(forecast.Weather[0].ID),
forecast.Temperature.Day,
forecast.Temperature.Min,
time.Unix(forecast.Timestamp, 0),
stationURL,
); err != nil {
log.Fatal(err)
}
}
}

View File

@ -1 +1,2 @@
42 * * * * camper /usr/bin/psql --quiet --output=/dev/null --command='set role guest; select * from camper.flush_payments()'
21 * * * * camper /usr/bin/camper-weather

View File

@ -1,3 +1,4 @@
usr/bin/camper usr/bin
usr/bin/camper-weather usr/bin
locale usr/share/camper
web usr/share/camper

2
debian/changelog vendored
View File

@ -1,4 +1,4 @@
camper (1.5~git00000000000000.0000000-1) bookworm; urgency=medium
camper (1.8~git00000000000000.0000000-1) bookworm; urgency=medium
* Initial release

4
debian/control vendored
View File

@ -10,6 +10,7 @@ Build-Depends:
golang-any,
golang-github-jackc-pgx-v4-dev,
golang-github-leonelquinteros-gotext-dev,
golang-github-rainycape-unidecode-dev,
golang-golang-x-text-dev,
postgresql-all (>= 217~),
sqitch,
@ -45,7 +46,8 @@ Architecture: any
Depends:
${shlibs:Depends},
${misc:Depends},
adduser
adduser,
weasyprint
Built-Using: ${misc:Built-Using}
Description: Simple campground reservation management application
A simple web application to manage reservations to campgrounds, intended for

View File

@ -10,8 +10,8 @@ values ('demo@camper', 'Demo User', 'demo', 'ca')
;
alter table company alter column company_id restart with 52;
insert into company (slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, rtc_number, tourist_tax, currency_code, default_lang_tag, legal_disclaimer)
values ('09184122-b276-4be2-9553-e4bbcbafe40d', 'El pont de Llierca, S.L.', 'ESB17377656', 'Càmping Montagut', parse_packed_phone_number('661 673 057', 'ES'), 'info@campingmontagut.com', 'https://campingmontagut.com/', 'Ctra. de Sadernes, Km 2', 'Montagut i Oix', 'Girona', '17855', 'ES', 'KG-000133', 60, 'EUR', 'ca', 'El pont de Llierca, S.L. és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a El pont de Llierca, S.L., amb domicili Ctra. de Sadernes, Km 2, 17855 Montagut i Oix o enviant un correu electrònic a info@campingmontagut.com. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a campingmontagut.com.');
insert into company (slug, business_name, vatin, trade_name, phone, email, web, address, city, province, postal_code, country_code, rtc_number, tourist_tax, tourist_tax_max_days, currency_code, default_lang_tag, legal_disclaimer)
values ('09184122-b276-4be2-9553-e4bbcbafe40d', 'El pont de Llierca, S.L.', 'ESB17377656', 'Càmping Montagut', parse_packed_phone_number('661 673 057', 'ES'), 'jordi@tandem.blog', 'https://tandem.blog/', 'Ctra. de Sadernes, Km 2', 'Montagut i Oix', 'Girona', '17855', 'ES', 'KG-000133', 60, 7, 'EUR', 'ca', 'El pont de Llierca, S.L. és responsable del tractament de les seves dades dacord amb el RGPD i la LOPDGDD, i les tracta per a mantenir una relació mercantil/comercial amb vostè. Les conservarà mentre es mantingui aquesta relació i no es comunicaran a tercers. Pot exercir els drets daccés, rectificació, portabilitat, supressió, limitació i oposició a El pont de Llierca, S.L., amb domicili Ctra. de Sadernes, Km 2, 17855 Montagut i Oix o enviant un correu electrònic a jordi@tandem.blog. Per a qualsevol reclamació pot acudir a agpd.es. Per a més informació pot consultar la nostra política de privacitat a tandem.blog.');
insert into company_host (company_id, host)
values (52, 'localhost:8080')
@ -24,6 +24,21 @@ values (52, 42, 'employee')
, (52, 43, 'admin')
;
insert into payment_method (payment_method_id, company_id, name, instructions)
values (1, 52, 'Pagament', '')
;
insert into tax_class (tax_class_id, company_id, name)
values (1, 52, 'VAT')
;
insert into tax (tax_id, company_id, tax_class_id, name, rate)
values (1, 52, 1, 'General VAT (21 %)', 0.21)
, (2, 52, 1, 'Reduced VAT (10 %)', 0.10)
, (3, 52, 1, 'Super-reduced VAT (4 %)', 0.04)
, (4, 52, 1, 'VAT free (0 %)', 0.00)
;
select setup_redsys(52, '361716962', '1', 'test', 'redirect', 'sq7HjrUOBfKmC576ILgskD5srU870gJ7');
select setup_location(52, '<div><h3>On som</h3><p>Ctra. de Sadernes, km 2, 17855 MONTAGUT i OIX</p></div>', '<iframe src="https://www.google.com/maps/embed?pb=!1m14!1m8!1m3!1d44661.89614700166!2d2.57381383167473!3d42.24000148364468!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x12bab79ff3b0f007%3A0x65b7563a5d1548e6!2sCamping%20Montagut!5e0!3m2!1sca!2sus!4v1703225042845!5m2!1sca!2sus" width="100%" height="600" style="border:0;" allowfullscreen="" loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>', '<div><p><strong>Càmping i Safari tents:</strong><br />de 08/04 a 09/10</p><p><strong>Cabanes i Bungalows:</strong><br />de 08/04 a 11/12</p><p><strong>ACSI</strong>:<br />de 08/04 a 11/12</p></div>');
@ -1233,6 +1248,15 @@ select set_season_range(93, daterange(make_date(extract(year from current_date):
select set_season_range(94, daterange(make_date(extract(year from current_date)::int, 9, 24), make_date(extract(year from current_date)::int, 9, 29)));
select set_season_range(93, daterange(make_date(extract(year from current_date)::int, 9, 29), make_date(extract(year from current_date)::int, 10, 1)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int, 10, 1), make_date(extract(year from current_date)::int, 10, 13)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 4, 11), make_date(extract(year from current_date)::int + 1, 4, 22)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int + 1, 4, 22), make_date(extract(year from current_date)::int + 1, 6, 20)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 6, 20), make_date(extract(year from current_date)::int + 1, 6, 25)));
select set_season_range(93, daterange(make_date(extract(year from current_date)::int + 1, 6, 25), make_date(extract(year from current_date)::int + 1, 7, 4)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 7, 4), make_date(extract(year from current_date)::int + 1, 8, 25)));
select set_season_range(93, daterange(make_date(extract(year from current_date)::int + 1, 8, 25), make_date(extract(year from current_date)::int + 1, 9, 1)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int + 1, 9, 1), make_date(extract(year from current_date)::int + 1, 9, 11)));
select set_season_range(92, daterange(make_date(extract(year from current_date)::int + 1, 9, 11), make_date(extract(year from current_date)::int + 1, 9, 15)));
select set_season_range(94, daterange(make_date(extract(year from current_date)::int + 1, 9, 15), make_date(extract(year from current_date)::int + 1, 12, 9)));
select set_campsite_type_cost (slug, 92, '4.00', '7.95', '7.95', '6.40') from campsite_type where campsite_type_id = 72;
select set_campsite_type_cost (slug, 93, '2.00', '7.40', '7.40', '5.90') from campsite_type where campsite_type_id = 72;
@ -1413,6 +1437,22 @@ values (72, 77, 'en', 'Legend')
, (76, 103, 'es', 'Leyenda')
;
insert into acsi (campsite_type_id, number_adults, number_teenagers, number_children, number_dogs, cost_per_night)
values (72, 2, 0, 0, 1, 2300);
insert into acsi_calendar (campsite_type_id, acsi_range)
values (72, daterange(make_date(extract(year from current_date)::int, 2, 4), make_date(extract(year from current_date)::int, 6, 20)))
, (72, daterange(make_date(extract(year from current_date)::int, 9, 1), make_date(extract(year from current_date)::int, 10, 13)))
;
insert into acsi_option (campsite_type_id, campsite_type_option_id, units, option_group)
values (72, 102, 1, 1)
, (72, 103, 1, 2)
, (72, 104, 1, 3)
, (72, 107, 1, 4)
, (72, 109, 1, 5)
;
alter table surroundings_highlight alter column surroundings_highlight_id restart with 112;
select add_surroundings_highlight(52, 62, 'El Pont del Llierca', '<p>Pont destil romànic i bany natural a 400 m del càmping.</p>');
@ -1471,19 +1511,34 @@ select translate_surroundings_ad(52, 'es', '¡Ven a hacer barranquismo en Sadern
select translate_surroundings_ad(52, 'fr', 'Venez faire du canyoning à Sadernes !', 'Réservez votre journée');
alter table booking alter column booking_id restart with 122;
insert into booking (company_id, campsite_type_id, holder_name, arrival_date, departure_date, number_dogs, acsi_card, booking_status)
values (52, 72, 'Juli Verd', current_date + interval '23 days', current_date + interval '25 days', 0, false, 'created')
, (52, 72, 'Pere Gil', current_date + interval '24 days', current_date + interval '25 days', 1, true, 'cancelled')
, (52, 73, 'Calèndula Groga', current_date + interval '24 days', current_date + interval '25 days', 0, false, 'confirmed')
, (52, 73, 'Rosa Blava', current_date + interval '15 days', current_date + interval '22 days', 0, false, 'checked-in')
, (52, 74, 'Margarita Blanca', current_date + interval '7 days', current_date + interval '8 days', 0, false, 'invoiced')
, (52, 74, 'Camèlia Vermella', current_date + interval '7 days', current_date + interval '8 days', 0, false, 'created')
, (52, 74, 'Valeriana Rosa', current_date + interval '3 days', current_date + interval '8 days', 0, true, 'cancelled')
, (52, 75, 'Jacint Violeta', current_date + interval '30 days', current_date + interval '33 days', 0, false, 'checked-in')
, (52, 76, 'Hortènsia Grisa', current_date + interval '29 days', current_date + interval '34 days', 0, false, 'invoiced')
insert into booking (company_id, campsite_type_id, holder_name, stay, zone_preferences, subtotal_nights, number_adults, subtotal_adults, number_teenagers, subtotal_teenagers, number_children, subtotal_children, number_dogs, subtotal_dogs, subtotal_tourist_tax, total, acsi_card, booking_status, currency_code)
values (52, 72, 'Juli Verd', daterange((current_date + interval '23 days')::date, (current_date + interval '25 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'created', 'EUR')
, (52, 72, 'Camèlia Vermella', daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'created', 'EUR')
, (52, 72, 'Margarita Blanca', daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'invoiced', 'EUR')
, (52, 72, 'Rosa Blava', daterange((current_date + interval '8 days')::date, (current_date + interval '11 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'checked-in', 'EUR')
, (52, 72, 'Calèndula Groga', daterange((current_date + interval '14 days')::date, (current_date + interval '21 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Jacint Violeta', daterange((current_date + interval '9 days')::date, (current_date + interval '13 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'checked-in', 'EUR')
, (52, 72, 'Hortènsia Grisa', daterange((current_date + interval '4 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'invoiced', 'EUR')
, (52, 72, 'Pere Gil', daterange((current_date + interval '9 days')::date, (current_date + interval '19 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, true, 'confirmed', 'EUR')
, (52, 72, 'Juli Verd', daterange((current_date + interval '11 days')::date, (current_date + interval '13 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Camèlia Vermella', daterange((current_date + interval '13 days')::date, (current_date + interval '15 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Valeriana Rosa', daterange((current_date + interval '15 days')::date, (current_date + interval '17 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, 'confirmed', 'EUR')
, (52, 72, 'Pere Gil', daterange((current_date + interval '24 days')::date, (current_date + interval '25 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, true, 'cancelled', 'EUR')
, (52, 72, 'Valeriana Rosa', daterange((current_date + interval '3 days')::date, (current_date + interval '8 days')::date), '', 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, true, 'cancelled', 'EUR')
;
insert into booking_campsite (booking_id, campsite_id, stay)
values (124, 90, daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date))
, (124, 94, daterange((current_date + interval '7 days')::date, (current_date + interval '8 days')::date))
, (125, 90, daterange((current_date + interval '8 days')::date, (current_date + interval '11 days')::date))
, (126, 90, daterange((current_date + interval '14 days')::date, (current_date + interval '21 days')::date))
, (127, 91, daterange((current_date + interval '9 days')::date, (current_date + interval '13 days')::date))
, (128, 92, daterange((current_date + interval '4 days')::date, (current_date + interval '8 days')::date))
, (129, 93, daterange((current_date + interval '9 days')::date, (current_date + interval '19 days')::date))
, (130, 94, daterange((current_date + interval '11 days')::date, (current_date + interval '13 days')::date))
, (131, 94, daterange((current_date + interval '13 days')::date, (current_date + interval '15 days')::date))
, (132, 94, daterange((current_date + interval '15 days')::date, (current_date + interval '17 days')::date))
;
alter table amenity alter column amenity_id restart with 132;
select add_amenity(52, 'camp-esport', 'Camp Esport', '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec scelerisque lorem vestibulum enim sollicitudin ornare. Aliquam egestas pretium porttitor. Donec iaculis tempus est, id lobortis risus semper vel. Maecenas ut imperdiet neque. Donec mattis purus felis, vitae interdum risus egestas pharetra. Vestibulum dui neque, condimentum ultrices erat sed, fringilla pharetra ante. Maecenas hendrerit neque mattis risus consectetur euismod. Cras urna metus, bibendum a neque sed, pharetra commodo magna.</p>', '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec scelerisque lorem vestibulum enim sollicitudin ornare. Aliquam egestas pretium porttitor. Donec iaculis tempus est, id lobortis risus semper vel. Maecenas ut imperdiet neque. Donec mattis purus felis, vitae interdum risus egestas pharetra. Vestibulum dui neque, condimentum ultrices erat sed, fringilla pharetra ante. Maecenas hendrerit neque mattis risus consectetur euismod. Cras urna metus, bibendum a neque sed, pharetra commodo magna.</p>');

23
deploy/acsi.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy camper:acsi to pg
-- requires: roles
-- requires: schema_camper
-- requires: campsite_type
begin;
set search_path to camper, public;
create table acsi (
campsite_type_id integer primary key references campsite_type,
number_adults positive_integer not null,
number_teenagers nonnegative_integer not null,
number_children nonnegative_integer not null,
number_dogs nonnegative_integer not null,
cost_per_night nonnegative_integer not null
);
grant select on table acsi to guest;
grant select on table acsi to employee;
grant select, insert, update, delete on table acsi to admin;
commit;

21
deploy/acsi_calendar.sql Normal file
View File

@ -0,0 +1,21 @@
-- Deploy camper:acsi_calendar to pg
-- requires: roles
-- requires: schema_camper
-- requires: acsi
begin;
set search_path to camper, public;
create table acsi_calendar (
campsite_type_id integer not null references acsi,
acsi_range daterange not null,
primary key (campsite_type_id, acsi_range),
constraint disallow_acsi_overlap exclude using gist (campsite_type_id with =, acsi_range with &&)
);
grant select on table acsi_calendar to guest;
grant select on table acsi_calendar to employee;
grant select, insert, update, delete on table acsi_calendar to admin;
commit;

22
deploy/acsi_option.sql Normal file
View File

@ -0,0 +1,22 @@
-- Deploy camper:acsi_option to pg
-- requires: roles
-- requires: schema_camper
-- requires: acsi
-- requires: campsite_type_option
begin;
set search_path to camper, public;
create table acsi_option (
campsite_type_id integer not null references acsi,
campsite_type_option_id integer not null references campsite_type_option,
units positive_integer not null,
primary key (campsite_type_id, campsite_type_option_id)
);
grant select on table acsi_option to guest;
grant select on table acsi_option to employee;
grant select, insert, update, delete on table acsi_option to admin;
commit;

View File

@ -0,0 +1,21 @@
-- Deploy camper:acsi_option__option_group to pg
-- requires: acsi_option
begin;
set search_path to camper, public;
alter table acsi_option
add column option_group integer not null default 0
;
alter table acsi_option
drop constraint if exists acsi_option_pkey
;
alter table acsi_option
add primary key (campsite_type_id, campsite_type_option_id, option_group)
, alter column option_group drop default
;
commit;

View File

@ -0,0 +1,108 @@
-- Deploy camper:add_booking_from_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_option
-- requires: payment
-- requires: payment__acsi_card
-- requires: payment_customer
-- requires: payment_option
begin;
set search_path to camper, public;
create or replace function add_booking_from_payment(payment_slug uuid) returns integer as
$$
declare
bid integer;
begin
insert into booking
( company_id
, campsite_type_id
, stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, lang_tag
)
select company_id
, campsite_type_id
, daterange(arrival_date, departure_date)
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, coalesce(full_name, 'Unknown')
, address
, postal_code
, city
, country_code
, email
, phone
, coalesce(lang_tag, 'und')
from payment
left join payment_customer using (payment_id)
where payment.slug = payment_slug
returning booking_id into bid;
if bid is null then
raise invalid_parameter_value using message = payment_slug || ' is not a valid payment.';
end if;
insert into booking_option
( booking_id
, campsite_type_option_id
, units
, subtotal
)
select bid
, campsite_type_option_id
, units
, subtotal
from payment_option
join payment using (payment_id)
where payment.slug = payment_slug
;
return bid;
end
$$
language plpgsql
;
revoke execute on function add_booking_from_payment(uuid) from public;
grant execute on function add_booking_from_payment(uuid) to employee;
grant execute on function add_booking_from_payment(uuid) to admin;
commit;

49
deploy/add_contact.sql Normal file
View File

@ -0,0 +1,49 @@
-- Deploy camper:add_contact to pg
-- requires: roles
-- requires: schema_camper
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: country_code
-- requires: contact
-- requires: contact_phone
-- requires: contact_email
begin;
set search_path to camper, public;
create or replace function add_contact(company_id integer, name text, id_document_type_id text, id_document_number text, phone text, email text, address text, city text, province text, postal_code text, country_code country_code) returns uuid as
$$
declare
cid integer;
cslug uuid;
begin
insert into contact (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code)
values (company_id, name, id_document_type_id, id_document_number, address, city, province, postal_code, country_code)
returning contact_id, slug
into cid, cslug
;
if phone is not null and trim(phone) <> '' then
insert into contact_phone (contact_id, phone)
values (cid, parse_packed_phone_number(add_contact.phone, coalesce(country_code, 'ES')))
;
end if;
if email is not null and trim(email) <> '' then
insert into contact_email (contact_id, email)
values (cid, add_contact.email)
;
end if;
return cslug;
end
$$
language plpgsql
;
revoke execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) from public;
grant execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) to employee;
grant execute on function add_contact(integer, text, text, text, text, text, text, text, text, text, country_code) to admin;
commit;

75
deploy/add_invoice.sql Normal file
View File

@ -0,0 +1,75 @@
-- Deploy camper:add_invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: invoice
-- requires: company
-- requires: currency
-- requires: parse_price
-- requires: new_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
-- requires: next_invoice_number
begin;
set search_path to camper, public;
create or replace function add_invoice(company integer, invoice_date date, contact_id integer, notes text, payment_method_id integer, products new_invoice_product[]) returns uuid as
$$
declare
iid integer;
pslug uuid;
product new_invoice_product;
ccode text;
ipid integer;
begin
insert into invoice (company_id, invoice_number, invoice_date, contact_id, notes, currency_code, payment_method_id)
select company_id
, next_invoice_number(add_invoice.company, invoice_date)
, invoice_date
, contact_id
, notes
, currency_code
, add_invoice.payment_method_id
from company
where company.company_id = add_invoice.company
returning invoice_id, slug, currency_code
into iid, pslug, ccode;
foreach product in array products
loop
insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate)
select iid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning invoice_product_id
into ipid;
if product.product_id is not null then
insert into invoice_product_product (invoice_product_id, product_id)
values (ipid, product.product_id);
end if;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
select ipid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
return pslug;
end;
$$
language plpgsql;
revoke execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) from public;
grant execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) to employee;
grant execute on function add_invoice(integer, date, integer, text, integer, new_invoice_product[]) to admin;
commit;

View File

@ -0,0 +1,39 @@
-- Deploy camper:available_id_document_types to pg
-- requires: id_document_type
-- requires: id_document_type_i18n
begin;
set search_path to camper;
insert into id_document_type (id_document_type_id, name)
values ('D', 'DNI')
, ('P', 'Passport')
, ('C', 'Driving license')
, ('I', 'Identification document')
, ('N', 'Spanish residence permit')
, ('X', 'Residence permit from another Member State of the European Union')
;
insert into id_document_type_i18n (id_document_type_id, lang_tag, name)
values ('D', 'ca', 'DNI')
, ('P', 'ca', 'Passaport')
, ('C', 'ca', 'Permís de conduir')
, ('I', 'ca', 'Carta o document didentitat')
, ('N', 'ca', 'Permís de residència espanyol')
, ('X', 'ca', 'Permís de residència dun altre estat membre de la Unió Europea')
, ('D', 'es', 'DNI')
, ('P', 'es', 'Pasaporte')
, ('C', 'es', 'Permiso de conducir')
, ('I', 'es', 'Carta o documento de identidad')
, ('N', 'es', 'Permiso de residencia español')
, ('X', 'es', 'Permiso de residencia de otro Estado Miembro de la Unión Europea')
, ('D', 'fr', 'DNI')
, ('P', 'fr', 'Passeport')
, ('C', 'fr', 'Permis de conduire')
, ('I', 'fr', 'Carte didentité')
, ('N', 'fr', 'Permis de séjour espagnol')
, ('X', 'fr', 'Titre de séjour dun autre État membre de lUnion européenne')
;
commit;

View File

@ -0,0 +1,28 @@
-- Deploy camper:available_invoice_status to pg
-- requires: schema_camper
-- requires: invoice_status
-- requires: invoice_status_i18n
begin;
set search_path to camper;
insert into invoice_status (invoice_status, name)
values ('created', 'Created')
, ('sent', 'Sent')
, ('paid', 'Paid')
, ('unpaid', 'Unpaid')
;
insert into invoice_status_i18n (invoice_status, lang_tag, name)
values ('created', 'ca', 'Creada')
, ('sent', 'ca', 'Enviada')
, ('paid', 'ca', 'Cobrada')
, ('unpaid', 'ca', 'No cobrada')
, ('created', 'es', 'Creada')
, ('sent', 'es', 'Enviada')
, ('paid', 'es', 'Cobrada')
, ('unpaid', 'es', 'No cobrada')
;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy camper:available_sexes to pg
-- requires: sex
-- requires: sex_i18n
begin;
set search_path to camper;
insert into sex (sex_id, name)
values ('F', 'Female')
, ('M', 'Male')
;
insert into sex_i18n (sex_id, lang_tag, name)
values ('F', 'ca', 'Femení')
, ('M', 'ca', 'Masculí')
, ('F', 'es', 'Femenino')
, ('M', 'es', 'Masculino')
, ('F', 'fr', 'Féminin')
, ('M', 'fr', 'Masculin')
;
commit;

View File

@ -0,0 +1,48 @@
-- Deploy camper:booking__payment_fields to pg
-- requires: booking
-- requires: positive_integer
-- requires: nonnegative_integer
begin;
set search_path to camper, public;
alter table booking
add column address text
, add column postal_code text
, add column city text
, add column country_code country_code references country
, add column email email
, add column phone packed_phone_number
, add lang_tag text not null default 'und' references language
, add zone_preferences text not null default ''
, add subtotal_nights nonnegative_integer not null default 0
, add number_adults positive_integer not null default 1
, add subtotal_adults nonnegative_integer not null default 0
, add number_teenagers nonnegative_integer not null default 0
, add subtotal_teenagers nonnegative_integer not null default 0
, add number_children nonnegative_integer not null default 0
, add subtotal_children nonnegative_integer not null default 0
, alter column number_dogs type nonnegative_integer
, add subtotal_dogs nonnegative_integer not null default 0
, add subtotal_tourist_tax nonnegative_integer not null default 0
, add total nonnegative_integer not null default 0
, add currency_code currency_code not null default 'EUR' references currency
;
alter table booking
alter column zone_preferences drop default
, alter column subtotal_nights drop default
, alter column number_adults drop default
, alter column subtotal_adults drop default
, alter column number_teenagers drop default
, alter column subtotal_teenagers drop default
, alter column number_children drop default
, alter column subtotal_children drop default
, alter column subtotal_dogs drop default
, alter column subtotal_tourist_tax drop default
, alter column total drop default
, alter column currency_code drop default
;
commit;

24
deploy/booking__stay.sql Normal file
View File

@ -0,0 +1,24 @@
-- Deploy camper:booking__stay to pg
-- requires: booking
begin;
set search_path to camper, public;
alter table booking
add column stay daterange constraint stay_not_empty check (not isempty(stay))
;
update booking
set stay = daterange(arrival_date, departure_date)
;
alter table booking
drop column if exists arrival_date
, drop column if exists departure_date
, alter column stay set not null
;
create index stay_idx on booking using gist (stay);
commit;

View File

@ -0,0 +1,25 @@
-- Deploy camper:booking_campsite to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: campsite
-- requires: extension_btree_gist
begin;
set search_path to camper, public;
create table booking_campsite (
booking_id integer not null references booking,
campsite_id integer not null references campsite,
stay daterange not null,
primary key (booking_id, campsite_id, stay),
exclude using gist (campsite_id with =, stay with &&)
);
create index booking_campsite_stay_idx on booking_campsite using gist (stay);
grant select, insert, update, delete on table booking_campsite to employee;
grant select, insert, update, delete on table booking_campsite to admin;
commit;

35
deploy/booking_guest.sql Normal file
View File

@ -0,0 +1,35 @@
-- Deploy camper:booking_guest to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: sex
-- requires: id_document_type
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create table booking_guest (
booking_guest_id integer generated by default as identity primary key,
booking_id integer not null references booking,
id_document_type_id varchar(1) not null references id_document_type,
id_document_number text not null,
id_document_issue_date date,
given_name text not null,
first_surname text not null,
second_surname text not null,
sex_id varchar(1) not null references sex,
birthdate date not null,
country_code country_code not null references country,
phone packed_phone_number,
address text not null,
created_at timestamp with time zone not null default current_timestamp,
updated_at timestamp with time zone not null default current_timestamp,
unique (booking_id, id_document_type_id, id_document_number)
);
grant select, insert, update, delete on table booking_guest to employee;
grant select, insert, update, delete on table booking_guest to admin;
commit;

View File

@ -0,0 +1,20 @@
-- Deploy camper:booking_invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: invoice
begin;
set search_path to camper, public;
create table booking_invoice (
booking_id integer not null references booking,
invoice_id integer not null references invoice,
primary key (booking_id, invoice_id)
);
grant select, insert, update, delete on table booking_invoice to employee;
grant select, insert, update, delete on table booking_invoice to admin;
commit;

24
deploy/booking_option.sql Normal file
View File

@ -0,0 +1,24 @@
-- Deploy camper:booking_option to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: campsite_type_option
-- requires: positive_integer
-- requires: nonnegative_integer
begin;
set search_path to camper, public;
create table booking_option (
booking_id integer not null references booking,
campsite_type_option_id integer not null references campsite_type_option,
units positive_integer not null,
subtotal nonnegative_integer not null,
primary key (booking_id, campsite_type_option_id)
);
grant select, insert, update, delete on table booking_option to employee;
grant select, insert, update, delete on table booking_option to admin;
commit;

View File

@ -0,0 +1,12 @@
-- Deploy camper:campsite_type__operating_dates to pg
-- requires: campsite_type
begin;
set search_path to camper, public;
alter table campsite_type
add column operating_dates daterange not null default 'empty'
;
commit;

23
deploy/cancel_booking.sql Normal file
View File

@ -0,0 +1,23 @@
-- Deploy camper:cancel_booking to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking_campsite
begin;
set search_path to camper, public;
create or replace function cancel_booking(bid integer) returns void as
$$
delete from booking_campsite where booking_id = bid;
update booking set booking_status = 'cancelled' where booking_id = bid;
$$
language sql
;
revoke execute on function cancel_booking(integer) from public;
grant execute on function cancel_booking(integer) to employee;
grant execute on function cancel_booking(integer) to admin;
commit;

View File

@ -0,0 +1,75 @@
-- Deploy camper:check_in_guests to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking_guest
-- requires: checked_in_guest
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create or replace function check_in_guests(bid integer, guests checked_in_guest[]) returns void as
$$
insert into booking_guest
( booking_id
, id_document_type_id
, id_document_number
, id_document_issue_date
, given_name
, first_surname
, second_surname
, sex_id
, birthdate
, country_code
, phone
, address
)
select bid
, id_document_type_id
, id_document_number
, id_document_issue_date
, given_name
, first_surname
, second_surname
, sex_id
, birthdate
, country_code
, case when phone is null or phone = '' then null else parse_packed_phone_number(phone, country_code) end
, address
from unnest(guests) as guest
on conflict (booking_id, id_document_type_id, id_document_number) do update
set id_document_type_id = excluded.id_document_type_id
, id_document_number = excluded.id_document_number
, id_document_issue_date = excluded.id_document_issue_date
, given_name = excluded.given_name
, first_surname = excluded.first_surname
, second_surname = excluded.second_surname
, sex_id = excluded.sex_id
, birthdate = excluded.birthdate
, country_code = excluded.country_code
, phone = excluded.phone
, address = excluded.address
, updated_at = current_timestamp
;
delete from booking_guest
where booking_id = bid
and updated_at < current_timestamp
;
update booking
set booking_status = 'checked-in'
where booking_id = bid
and booking_status in ('created', 'confirmed')
;
$$
language sql
;
revoke execute on function check_in_guests(integer, checked_in_guest[]) from public;
grant execute on function check_in_guests(integer, checked_in_guest[]) to employee;
grant execute on function check_in_guests(integer, checked_in_guest[]) to admin;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy camper:checked_in_guest to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create type checked_in_guest as (
id_document_type_id varchar(1),
id_document_number text,
id_document_issue_date date,
given_name text,
first_surname text,
second_surname text,
sex_id varchar(1),
birthdate date,
country_code country_code,
phone text,
address text
);
commit;

View File

@ -0,0 +1,64 @@
-- Deploy camper:compute_new_invoice_amount to pg
-- requires: schema_camper
-- requires: company
-- requires: currency
-- requires: tax
-- requires: new_invoice_product
-- requires: new_invoice_amount
begin;
set search_path to camper, public;
create or replace function compute_new_invoice_amount(company_id integer, products new_invoice_product[]) returns new_invoice_amount as
$$
declare
result new_invoice_amount;
begin
if array_length(products, 1) is null then
select to_price(0, decimal_digits), array[]::text[][], to_price(0, decimal_digits)
from company
join currency using (currency_code)
where company.company_id = compute_new_invoice_amount.company_id
into result.subtotal, result.taxes, result.total;
else
with product as (
select round(parse_price(price, currency.decimal_digits) * quantity * (1 - discount_rate))::integer as subtotal
, tax
, decimal_digits
from unnest(products)
join company on company.company_id = compute_new_invoice_amount.company_id
join currency using (currency_code)
)
, tax_amount as (
select tax_id
, sum(round(subtotal * tax.rate)::integer)::integer as amount
, decimal_digits
from product, unnest(product.tax) as product_tax(tax_id)
join tax using (tax_id)
group by tax_id, decimal_digits
)
, tax_total as (
select sum(amount)::integer as amount, array_agg(array[name, to_price(amount, decimal_digits)]) as taxes
from tax_amount
join tax using (tax_id)
)
select to_price(sum(subtotal)::integer, decimal_digits)
, coalesce(taxes, array[]::text[][])
, to_price(sum(subtotal)::integer + coalesce(tax_total.amount, 0), decimal_digits) as total
from product, tax_total
group by tax_total.amount, taxes, decimal_digits
into result.subtotal, result.taxes, result.total;
end if;
return result;
end
$$
language plpgsql
stable;
revoke execute on function compute_new_invoice_amount(integer, new_invoice_product[]) from public;
grant execute on function compute_new_invoice_amount(integer, new_invoice_product[]) to employee;
grant execute on function compute_new_invoice_amount(integer, new_invoice_product[]) to admin;
commit;

45
deploy/contact.sql Normal file
View File

@ -0,0 +1,45 @@
-- Deploy camper:contact to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
-- requires: id_document_type
-- requires: country_code
-- requires: country
begin;
set search_path to camper, public;
create table contact (
contact_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
name text not null constraint name_not_empty check(length(trim(name)) > 1),
id_document_type_id varchar(1) not null references id_document_type,
id_document_number text not null,
address text not null,
city text not null,
province text not null,
postal_code text not null,
country_code country_code not null references country,
created_at timestamptz not null default current_timestamp
);
grant select, insert, update, delete on table contact to employee;
grant select, insert, update, delete on table contact to admin;
alter table contact enable row level security;
create policy company_policy
on contact
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = contact.company_id
)
);
commit;

31
deploy/contact_email.sql Normal file
View File

@ -0,0 +1,31 @@
-- Deploy camper:contact_email to pg
-- requires: roles
-- requires: schema_camper
-- requires: email
-- requires: contact
begin;
set search_path to camper, public;
create table contact_email (
contact_id integer primary key references contact,
email email not null
);
grant select, insert, update, delete on table contact_email to employee;
grant select, insert, update, delete on table contact_email to admin;
alter table contact_email enable row level security;
create policy company_policy
on contact_email
using (
exists(
select 1
from contact
where contact.contact_id = contact_email.contact_id
)
);
commit;

30
deploy/contact_phone.sql Normal file
View File

@ -0,0 +1,30 @@
-- Deploy camper:contact_phone to pg
-- requires: roles
-- requires: schema_camper
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create table contact_phone (
contact_id integer primary key references contact,
phone packed_phone_number not null
);
grant select, insert, update, delete on table contact_phone to employee;
grant select, insert, update, delete on table contact_phone to admin;
alter table contact_phone enable row level security;
create policy company_policy
on contact_phone
using (
exists(
select 1
from contact
where contact.contact_id = contact_phone.contact_id
)
);
commit;

14
deploy/discount_rate.sql Normal file
View File

@ -0,0 +1,14 @@
-- Deploy camper:discount_rate to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create domain discount_rate as numeric
check (VALUE >= 0 and VALUE <= 1);
comment on domain discount_rate is
'A rate for discount in the range [0, 1]';
commit;

View File

@ -11,12 +11,16 @@
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
-- requires: acsi
-- requires: acsi_calendar
-- requires: acsi_option
-- requires: acsi_option__option_group
begin;
set search_path to camper, public;
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, options option_units[]) returns payment as
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, acsi_card boolean, options option_units[]) returns payment as
$$
declare
p payment;
@ -44,26 +48,28 @@ begin
, currency_code
, down_payment_percent
, zone_preferences
, acsi_card
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type_id
, campsite_type.campsite_type_id
, arrival_date
, departure_date
, sum(cost.cost_per_night * ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer)::integer
, sum(coalesce(acsi.cost_per_night, 0) + cost.cost_per_night * (ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer - case when acsi.cost_per_night is null then 0 else 1 end))::integer
, num_adults
, sum(cost_per_adult * num_adults)::integer
, sum(cost_per_adult * greatest(0, num_adults - coalesce(acsi.number_adults, 0)))::integer
, num_teenagers
, sum(cost_per_teenager * num_teenagers)::integer
, sum(cost_per_teenager * greatest(0, num_teenagers - coalesce(acsi.number_teenagers, 0)))::integer
, num_children
, sum(cost_per_child * num_children)::integer
, sum(cost_per_child * greatest(0, num_children - coalesce(acsi.number_children, 0)))::integer
, num_dogs
, sum(case when num_dogs > 0 then coalesce(pet.cost_per_night, 0) else 0 end)::integer
, sum(coalesce(pet.cost_per_night, 0) * greatest(0, num_dogs - coalesce(acsi.number_dogs, 0)))::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
, acsi_card
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
@ -71,9 +77,13 @@ begin
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
left join (acsi join acsi_calendar using (campsite_type_id)) as acsi
on acsi_card
and acsi.campsite_type_id = campsite_type.campsite_type_id
and date.day::date <@ acsi_range
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type_id
, campsite_type.campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
@ -94,6 +104,7 @@ begin
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, acsi_card = excluded.acsi_card
, updated_at = current_timestamp
returning *
into p
@ -108,6 +119,26 @@ begin
from unnest(options) as option(campsite_type_option_id, units)
);
with discountable_acsi_option as (
select distinct day, campsite_type_option_id, units
from (
select day, campsite_type_option_id, units, row_number() over (partition by day, option_group order by cost desc) as rn
from (
select day, campsite_type_option_id, units, cost, min(option_group) option_group from (
select day, campsite_type_option_id, acsi_option.units, cost, option_group, count(*) over (partition by day, option_group) > 1 as already_used
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
join acsi_calendar on acsi_card and day::date <@ acsi_range
join acsi_option as acsi_option using (campsite_type_option_id)
) as with_count
group by day, campsite_type_option_id, units, cost, already_used
) as by_group
) as discountable
where acsi_card
and rn = 1
)
insert into payment_option (
payment_id
, campsite_type_option_id
@ -115,16 +146,17 @@ begin
, subtotal
)
select p.payment_id
, campsite_type_option_id
, units
, case when per_night then sum(cost * units)::integer else max(cost * units)::integer end
, campsite_type_option.campsite_type_option_id
, option.units
, case when per_night then sum(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer else max(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
group by campsite_type_option_id
, units
left join discountable_acsi_option as acsi_option using (day, campsite_type_option_id)
group by campsite_type_option.campsite_type_option_id
, option.units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
@ -161,9 +193,9 @@ $$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to admin;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to admin;
commit;

169
deploy/draft_payment@v5.sql Normal file
View File

@ -0,0 +1,169 @@
-- Deploy camper:draft_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: season_calendar
-- requires: season
-- requires: campsite_type
-- requires: campsite_type_pet_cost
-- requires: campsite_type_cost
-- requires: campsite_type_option_cost
-- requires: campsite_type_option
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
begin;
set search_path to camper, public;
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, options option_units[]) returns payment as
$$
declare
p payment;
begin
if exists(select 1 from payment where slug = payment_slug and payment_status <> 'draft') then
payment_slug = null;
end if;
insert into payment (
slug
, company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, down_payment_percent
, zone_preferences
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type_id
, arrival_date
, departure_date
, sum(cost.cost_per_night * ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer)::integer
, num_adults
, sum(cost_per_adult * num_adults)::integer
, num_teenagers
, sum(cost_per_teenager * num_teenagers)::integer
, num_children
, sum(cost_per_child * num_children)::integer
, num_dogs
, sum(case when num_dogs > 0 then coalesce(pet.cost_per_night, 0) else 0 end)::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
left join campsite_type using (company_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
, campsite_type_id = excluded.campsite_type_id
, arrival_date = excluded.arrival_date
, departure_date = excluded.departure_date
, subtotal_nights = excluded.subtotal_nights
, number_adults = excluded.number_adults
, subtotal_adults = excluded.subtotal_adults
, number_teenagers = excluded.number_teenagers
, subtotal_teenagers = excluded.subtotal_teenagers
, number_children = excluded.number_children
, subtotal_children = excluded.subtotal_children
, number_dogs = excluded.number_dogs
, subtotal_dogs = excluded.subtotal_dogs
, subtotal_tourist_tax = excluded.subtotal_tourist_tax
, total = excluded.total
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, updated_at = current_timestamp
returning *
into p
;
if array_length(coalesce(options, array[]::option_units[]), 1) > 0 then
delete
from payment_option
where payment_id = p.payment_id
and campsite_type_option_id not in (
select campsite_type_option_id
from unnest(options) as option(campsite_type_option_id, units)
);
insert into payment_option (
payment_id
, campsite_type_option_id
, units
, subtotal
)
select p.payment_id
, campsite_type_option_id
, units
, case when per_night then sum(cost * units)::integer else max(cost * units)::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
group by campsite_type_option_id
, units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
, subtotal = excluded.subtotal
;
with option as (
select sum(subtotal)::integer as subtotal
from payment_option
where payment_id = p.payment_id
)
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax + coalesce(option.subtotal, 0)
from option
where payment_id = p.payment_id
returning total into p.total
;
else
delete
from payment_option
where payment_id = p.payment_id;
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax
where payment_id = p.payment_id
returning total into p.total
;
end if;
return p;
end;
$$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]) to admin;
commit;

183
deploy/draft_payment@v6.sql Normal file
View File

@ -0,0 +1,183 @@
-- Deploy camper:draft_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: season_calendar
-- requires: season
-- requires: campsite_type
-- requires: campsite_type_pet_cost
-- requires: campsite_type_cost
-- requires: campsite_type_option_cost
-- requires: campsite_type_option
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
-- requires: acsi
-- requires: acsi_calendar
-- requires: acsi_options
begin;
set search_path to camper, public;
drop function if exists draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, option_units[]);
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, acsi_card boolean, options option_units[]) returns payment as
$$
declare
p payment;
begin
if exists(select 1 from payment where slug = payment_slug and payment_status <> 'draft') then
payment_slug = null;
end if;
insert into payment (
slug
, company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, down_payment_percent
, zone_preferences
, acsi_card
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type.campsite_type_id
, arrival_date
, departure_date
, sum(coalesce(acsi.cost_per_night, 0) + cost.cost_per_night * (ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer - case when acsi.cost_per_night is null then 0 else 1 end))::integer
, num_adults
, sum(cost_per_adult * greatest(0, num_adults - coalesce(acsi.number_adults, 0)))::integer
, num_teenagers
, sum(cost_per_teenager * greatest(0, num_teenagers - coalesce(acsi.number_teenagers, 0)))::integer
, num_children
, sum(cost_per_child * greatest(0, num_children - coalesce(acsi.number_children, 0)))::integer
, num_dogs
, sum(case when (num_dogs - coalesce(acsi.number_dogs, 0)) > 0 then coalesce(pet.cost_per_night, 0) else 0 end)::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
, acsi_card
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
left join campsite_type using (company_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
left join (acsi join acsi_calendar using (campsite_type_id)) as acsi
on acsi_card
and acsi.campsite_type_id = campsite_type.campsite_type_id
and date.day::date <@ acsi_range
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type.campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
, campsite_type_id = excluded.campsite_type_id
, arrival_date = excluded.arrival_date
, departure_date = excluded.departure_date
, subtotal_nights = excluded.subtotal_nights
, number_adults = excluded.number_adults
, subtotal_adults = excluded.subtotal_adults
, number_teenagers = excluded.number_teenagers
, subtotal_teenagers = excluded.subtotal_teenagers
, number_children = excluded.number_children
, subtotal_children = excluded.subtotal_children
, number_dogs = excluded.number_dogs
, subtotal_dogs = excluded.subtotal_dogs
, subtotal_tourist_tax = excluded.subtotal_tourist_tax
, total = excluded.total
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, acsi_card = excluded.acsi_card
, updated_at = current_timestamp
returning *
into p
;
if array_length(coalesce(options, array[]::option_units[]), 1) > 0 then
delete
from payment_option
where payment_id = p.payment_id
and campsite_type_option_id not in (
select campsite_type_option_id
from unnest(options) as option(campsite_type_option_id, units)
);
insert into payment_option (
payment_id
, campsite_type_option_id
, units
, subtotal
)
select p.payment_id
, campsite_type_option.campsite_type_option_id
, option.units
, case when per_night then sum(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer else max(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
left join acsi_calendar on acsi_card and day::date <@ acsi_range
left join acsi_option on acsi_option.campsite_type_id = acsi_calendar.campsite_type_id and acsi_option.campsite_type_option_id = campsite_type_option.campsite_type_option_id
group by campsite_type_option.campsite_type_option_id
, option.units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
, subtotal = excluded.subtotal
;
with option as (
select sum(subtotal)::integer as subtotal
from payment_option
where payment_id = p.payment_id
)
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax + coalesce(option.subtotal, 0)
from option
where payment_id = p.payment_id
returning total into p.total
;
else
delete
from payment_option
where payment_id = p.payment_id;
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax
where payment_id = p.payment_id
returning total into p.total
;
end if;
return p;
end;
$$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to admin;
commit;

194
deploy/draft_payment@v7.sql Normal file
View File

@ -0,0 +1,194 @@
-- Deploy camper:draft_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: season_calendar
-- requires: season
-- requires: campsite_type
-- requires: campsite_type_pet_cost
-- requires: campsite_type_cost
-- requires: campsite_type_option_cost
-- requires: campsite_type_option
-- requires: payment
-- requires: payment_option
-- requires: company__tourist_tax_max_days
-- requires: acsi
-- requires: acsi_calendar
-- requires: acsi_options
begin;
set search_path to camper, public;
create or replace function draft_payment(payment_slug uuid, arrival_date date, departure_date date, campsite_type_slug uuid, num_adults integer, num_teenagers integer, num_children integer, num_dogs integer, zone_preferences text, acsi_card boolean, options option_units[]) returns payment as
$$
declare
p payment;
begin
if exists(select 1 from payment where slug = payment_slug and payment_status <> 'draft') then
payment_slug = null;
end if;
insert into payment (
slug
, company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, down_payment_percent
, zone_preferences
, acsi_card
)
select coalesce(payment_slug, gen_random_uuid())
, company_id
, campsite_type.campsite_type_id
, arrival_date
, departure_date
, sum(coalesce(acsi.cost_per_night, 0) + cost.cost_per_night * (ceiling((num_adults::numeric + num_teenagers::numeric + num_children::numeric) / max_campers::numeric)::integer - case when acsi.cost_per_night is null then 0 else 1 end))::integer
, num_adults
, sum(cost_per_adult * greatest(0, num_adults - coalesce(acsi.number_adults, 0)))::integer
, num_teenagers
, sum(cost_per_teenager * greatest(0, num_teenagers - coalesce(acsi.number_teenagers, 0)))::integer
, num_children
, sum(cost_per_child * greatest(0, num_children - coalesce(acsi.number_children, 0)))::integer
, num_dogs
, sum(coalesce(pet.cost_per_night, 0) * greatest(0, num_dogs - coalesce(acsi.number_dogs, 0)))::integer
, sum(case when day_num <= tourist_tax_max_days then tourist_tax * num_adults else 0 end)::integer
, 0
, currency_code
, case when arrival_date - current_date >= 7 then 0.3 else 1.0 end
, coalesce(zone_preferences, '')
, acsi_card
from generate_series(arrival_date, departure_date - 1, interval '1 day') with ordinality as date(day, day_num)
left join season_calendar on season_range @> date.day::date
left join season using (season_id)
left join campsite_type using (company_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join campsite_type_cost as cost using (campsite_type_id, season_id)
left join company using (company_id)
left join (acsi join acsi_calendar using (campsite_type_id)) as acsi
on acsi_card
and acsi.campsite_type_id = campsite_type.campsite_type_id
and date.day::date <@ acsi_range
where campsite_type.slug = campsite_type_slug
group by company_id
, campsite_type.campsite_type_id
, currency_code
on conflict (slug) do update
set company_id = excluded.company_id
, campsite_type_id = excluded.campsite_type_id
, arrival_date = excluded.arrival_date
, departure_date = excluded.departure_date
, subtotal_nights = excluded.subtotal_nights
, number_adults = excluded.number_adults
, subtotal_adults = excluded.subtotal_adults
, number_teenagers = excluded.number_teenagers
, subtotal_teenagers = excluded.subtotal_teenagers
, number_children = excluded.number_children
, subtotal_children = excluded.subtotal_children
, number_dogs = excluded.number_dogs
, subtotal_dogs = excluded.subtotal_dogs
, subtotal_tourist_tax = excluded.subtotal_tourist_tax
, total = excluded.total
, currency_code = excluded.currency_code
, down_payment_percent = excluded.down_payment_percent
, zone_preferences = excluded.zone_preferences
, acsi_card = excluded.acsi_card
, updated_at = current_timestamp
returning *
into p
;
if array_length(coalesce(options, array[]::option_units[]), 1) > 0 then
delete
from payment_option
where payment_id = p.payment_id
and campsite_type_option_id not in (
select campsite_type_option_id
from unnest(options) as option(campsite_type_option_id, units)
);
with discountable_acsi_option as (
select day, campsite_type_option_id, units
from (
select day, campsite_type_option_id, acsi_option.units, row_number() over (partition by day order by cost desc) as rn
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
join acsi_calendar on acsi_card and day::date <@ acsi_range
join acsi_option as acsi_option using (campsite_type_option_id)
) as discountable
where acsi_card
and rn = 1
)
insert into payment_option (
payment_id
, campsite_type_option_id
, units
, subtotal
)
select p.payment_id
, campsite_type_option.campsite_type_option_id
, option.units
, case when per_night then sum(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer else max(cost * greatest(0, option.units - coalesce(acsi_option.units, 0)))::integer end
from generate_series(arrival_date, departure_date - 1, interval '1 day') as date(day)
join season_calendar on season_range @> date.day::date
join campsite_type_option_cost using (season_id)
join campsite_type_option using (campsite_type_option_id)
join unnest(options) as option(campsite_type_option_id, units) using (campsite_type_option_id)
left join discountable_acsi_option as acsi_option using (day, campsite_type_option_id)
group by campsite_type_option.campsite_type_option_id
, option.units
, per_night
on conflict (payment_id, campsite_type_option_id) do update
set units = excluded.units
, subtotal = excluded.subtotal
;
with option as (
select sum(subtotal)::integer as subtotal
from payment_option
where payment_id = p.payment_id
)
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax + coalesce(option.subtotal, 0)
from option
where payment_id = p.payment_id
returning total into p.total
;
else
delete
from payment_option
where payment_id = p.payment_id;
update payment
set total = subtotal_nights + subtotal_adults + subtotal_teenagers + subtotal_children + subtotal_dogs + subtotal_tourist_tax
where payment_id = p.payment_id
returning total into p.total
;
end if;
return p;
end;
$$
language plpgsql
;
revoke execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) from public;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to guest;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to employee;
grant execute on function draft_payment(uuid, date, date, uuid, integer, integer, integer, integer, text, boolean, option_units[]) to admin;
commit;

48
deploy/edit_booking.sql Normal file
View File

@ -0,0 +1,48 @@
-- Deploy camper:edit_booking to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_campsite
begin;
set search_path to camper, public;
create or replace function edit_booking(bid integer, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code text, customer_email email, customer_phone text, customer_lang_tag text, booking_status text, campsite_ids integer[]) returns void as
$$
begin
update booking
set holder_name = customer_name
, address = customer_address
, postal_code = customer_post_code
, city = customer_city
, country_code = customer_country_code
, email = customer_email
, phone = case when customer_phone is null then null else parse_packed_phone_number(customer_phone, coalesce(customer_country_code, 'ES')) end
, lang_tag = coalesce(customer_lang_tag, 'und')
, booking_status = edit_booking.booking_status
where booking_id = bid
;
delete from booking_campsite
where booking_id = bid;
insert into booking_campsite
select bid
, campsite_id
, stay
from booking, unnest(campsite_ids) as campsite(campsite_id)
where booking_id = bid
;
end
$$
language plpgsql
;
revoke execute on function edit_booking(integer, text, text, text, text, text, email, text, text, text, integer[]) from public;
grant execute on function edit_booking(integer, text, text, text, text, text, email, text, text, text, integer[]) to employee;
grant execute on function edit_booking(integer, text, text, text, text, text, email, text, text, text, integer[]) to admin;
commit;

View File

@ -0,0 +1,92 @@
-- Deploy camper:edit_booking_from_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking__payment_fields
-- requires: booking__stay
-- requires: booking_option
-- requires: payment
-- requires: payment__acsi_card
-- requires: payment_option
begin;
set search_path to camper, public;
create or replace function edit_booking_from_payment(booking_slug uuid, payment_slug uuid) returns integer as
$$
declare
bid integer;
begin
with p as (
select company_id
, campsite_type_id
, daterange(arrival_date, departure_date) as stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
from payment
where payment.slug = payment_slug
)
update booking
set company_id = p.company_id
, campsite_type_id = p.campsite_type_id
, stay = p.stay
, subtotal_nights = p.subtotal_nights
, number_adults = p.number_adults
, subtotal_adults = p.subtotal_adults
, number_teenagers = p.number_teenagers
, subtotal_teenagers = p.subtotal_teenagers
, number_children = p.number_children
, subtotal_children = p.subtotal_children
, number_dogs = p.number_dogs
, subtotal_dogs = p.subtotal_dogs
, subtotal_tourist_tax = p.subtotal_tourist_tax
, total = p.total
, currency_code = p.currency_code
, zone_preferences = p.zone_preferences
, acsi_card = p.acsi_card
from p
where slug = booking_slug
returning booking_id into bid;
delete from booking_option
where booking_id = bid;
insert into booking_option
( booking_id
, campsite_type_option_id
, units
, subtotal
)
select bid
, campsite_type_option_id
, units
, subtotal
from payment_option
join payment using (payment_id)
where payment.slug = payment_slug
;
return bid;
end;
$$
language plpgsql
;
revoke execute on function edit_booking_from_payment(uuid, uuid) from public;
grant execute on function edit_booking_from_payment(uuid, uuid) to employee;
grant execute on function edit_booking_from_payment(uuid, uuid) to admin;
commit;

72
deploy/edit_contact.sql Normal file
View File

@ -0,0 +1,72 @@
-- Deploy camper:edit_contact to pg
-- requires: roles
-- requires: schema_camper
-- requires: email
-- requires: country_code
-- requires: contact
-- requires: extension_pg_libphonenumber
-- requires: contact_phone
-- requires: contact_email
begin;
set search_path to camper, public;
create or replace function edit_contact(contact_slug uuid, name text, id_document_type_id text, id_document_number text, phone text, email text, address text, city text, province text, postal_code text, country_code country_code) returns uuid as
$$
declare
cid integer;
begin
update contact
set name = edit_contact.name
, id_document_type_id = edit_contact.id_document_type_id
, id_document_number = edit_contact.id_document_number
, address = edit_contact.address
, city = edit_contact.city
, province = edit_contact.province
, postal_code = edit_contact.postal_code
, country_code = edit_contact.country_code
where slug = contact_slug
returning contact_id
into cid
;
if cid is null then
return null;
end if;
if phone is null or trim(phone) = '' then
delete from contact_phone
where contact_id = cid
;
else
insert into contact_phone (contact_id, phone)
values (cid, parse_packed_phone_number(phone, coalesce(country_code, 'ES')))
on conflict (contact_id) do update
set phone = excluded.phone
;
end if;
if email is null or trim(email) = '' then
delete from contact_email
where contact_id = cid
;
else
insert into contact_email (contact_id, email)
values (cid, email)
on conflict (contact_id) do update
set email = excluded.email
;
end if;
return contact_slug;
end
$$
language plpgsql
;
revoke execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) from public;
grant execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) to employee;
grant execute on function edit_contact(uuid, text, text, text, text, text, text, text, text, text, country_code) to admin;
commit;

110
deploy/edit_invoice.sql Normal file
View File

@ -0,0 +1,110 @@
-- Deploy camper:edit_invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: invoice
-- requires: currency
-- requires: parse_price
-- requires: edited_invoice_product
-- requires: tax
-- requires: invoice_product
-- requires: invoice_product_product
-- requires: invoice_product_tax
begin;
set search_path to camper, public;
create or replace function edit_invoice(invoice_slug uuid, invoice_status text, contact_id integer, notes text, payment_method_id integer, products edited_invoice_product[]) returns uuid as
$$
declare
iid integer;
products_to_keep integer[];
products_to_delete integer[];
company integer;
ccode text;
product edited_invoice_product;
ipid integer;
begin
update invoice
set contact_id = edit_invoice.contact_id
, invoice_status = edit_invoice.invoice_status
, notes = edit_invoice.notes
, payment_method_id = edit_invoice.payment_method_id
where slug = invoice_slug
returning invoice_id, company_id, currency_code
into iid, company, ccode
;
if iid is null then
return null;
end if;
foreach product in array products
loop
if product.invoice_product_id is null then
insert into invoice_product (invoice_id, name, description, price, quantity, discount_rate)
select iid
, product.name
, coalesce(product.description, '')
, parse_price(product.price, currency.decimal_digits)
, product.quantity
, product.discount_rate
from currency
where currency_code = ccode
returning invoice_product_id
into ipid;
else
ipid := product.invoice_product_id;
update invoice_product
set name = product.name
, description = coalesce(product.description, '')
, price = parse_price(product.price, currency.decimal_digits)
, quantity = product.quantity
, discount_rate = product.discount_rate
from currency
where invoice_product_id = ipid
and currency_code = ccode;
end if;
products_to_keep := array_append(products_to_keep, ipid);
if product.product_id is null then
delete from invoice_product_product where invoice_product_id = ipid;
else
insert into invoice_product_product (invoice_product_id, product_id)
values (ipid, product.product_id)
on conflict (invoice_product_id) do update
set product_id = product.product_id;
end if;
delete from invoice_product_tax where invoice_product_id = ipid;
insert into invoice_product_tax (invoice_product_id, tax_id, tax_rate)
select ipid, tax_id, tax.rate
from tax
join unnest(product.tax) as ptax(tax_id) using (tax_id);
end loop;
select array_agg(invoice_product_id)
into products_to_delete
from invoice_product
where invoice_id = iid
and not (invoice_product_id = any(products_to_keep));
if array_length(products_to_delete, 1) > 0 then
delete from invoice_product_tax where invoice_product_id = any(products_to_delete);
delete from invoice_product_product where invoice_product_id = any(products_to_delete);
delete from invoice_product where invoice_product_id = any(products_to_delete);
end if;
return invoice_slug;
end;
$$
language plpgsql;
revoke execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) from public;
grant execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) to employee;
grant execute on function edit_invoice(uuid, text, integer, text, integer, edited_invoice_product[]) to admin;
commit;

View File

@ -0,0 +1,20 @@
-- Deploy camper:edited_invoice_product to pg
-- requires: schema_camper
-- requires: discount_rate
begin;
set search_path to camper, public;
create type edited_invoice_product as
( invoice_product_id integer
, product_id integer
, name text
, description text
, price text
, quantity integer
, discount_rate discount_rate
, tax integer[]
);
commit;

View File

@ -0,0 +1,17 @@
-- Deploy camper:id_document_type to pg
-- requires: roles
-- requires: schema_camper
begin;
set search_path to camper, public;
create table id_document_type (
id_document_type_id varchar(1) primary key,
name text not null
);
grant select on table id_document_type to employee;
grant select on table id_document_type to admin;
commit;

View File

@ -0,0 +1,21 @@
-- Deploy camper:id_document_type_i18n to pg
-- requires: roles
-- requires: schema_camper
-- requires: id_document_type
-- requires: language
begin;
set search_path to camper, public;
create table id_document_type_i18n (
id_document_type_id varchar(1) not null references id_document_type,
lang_tag text not null references language,
name text not null,
primary key (id_document_type_id, lang_tag)
);
grant select on table id_document_type_i18n to employee;
grant select on table id_document_type_i18n to admin;
commit;

44
deploy/invoice.sql Normal file
View File

@ -0,0 +1,44 @@
-- Deploy camper:invoice to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
-- requires: contact
-- requires: invoice_status
-- requires: currency
begin;
set search_path to camper, public;
create table invoice (
invoice_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null unique default gen_random_uuid(),
invoice_number text not null constraint invoice_number_not_empty check(length(trim(invoice_number)) > 1),
invoice_date date not null default current_date,
contact_id integer not null references contact,
invoice_status text not null default 'created' references invoice_status,
notes text not null default '',
payment_method_id integer not null references payment_method,
currency_code text not null references currency,
created_at timestamptz not null default current_timestamp
);
grant select, insert, update, delete on table invoice to employee;
grant select, insert, update, delete on table invoice to admin;
alter table invoice enable row level security;
create policy company_policy
on invoice
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = invoice.company_id
)
);
commit;

22
deploy/invoice_amount.sql Normal file
View File

@ -0,0 +1,22 @@
-- Deploy camper:invoice_amount to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: invoice_product_amount
begin;
set search_path to camper, public;
create or replace view invoice_amount as
select invoice_id
, sum(subtotal)::integer as subtotal
, sum(total)::integer as total
from invoice_product
join invoice_product_amount using (invoice_product_id)
group by invoice_id
;
grant select on table invoice_amount to employee;
grant select on table invoice_amount to admin;
commit;

View File

@ -0,0 +1,32 @@
-- Deploy camper:invoice_number_counter to pg
-- requires: schema_camper
-- requires: company
begin;
set search_path to camper, public;
create table invoice_number_counter (
company_id integer not null references company,
year integer not null constraint year_always_positive check(year > 0),
currval integer not null constraint counter_zero_or_positive check(currval >= 0),
primary key (company_id, year)
);
grant select, insert, update on table invoice_number_counter to employee;
grant select, insert, update on table invoice_number_counter to admin;
alter table invoice_number_counter enable row level security;
create policy company_policy
on invoice_number_counter
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = invoice_number_counter.company_id
)
);
commit;

View File

@ -0,0 +1,38 @@
-- Deploy camper:invoice_product to pg
-- requires: schema_camper
-- requires: invoice
-- requires: discount_rate
begin;
set search_path to camper, public;
create table invoice_product (
invoice_product_id integer generated by default as identity primary key,
invoice_id integer not null references invoice,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
description text not null default '',
price integer not null,
quantity integer not null default 1,
discount_rate discount_rate not null default 0.0
);
grant select, insert, update, delete on table invoice_product to employee;
grant select, insert, update, delete on table invoice_product to admin;
grant usage on sequence invoice_product_invoice_product_id_seq to employee;
grant usage on sequence invoice_product_invoice_product_id_seq to admin;
alter table invoice_product enable row level security;
create policy company_policy
on invoice_product
using (
exists(
select 1
from invoice
where invoice.invoice_id = invoice_product.invoice_id
)
);
commit;

View File

@ -0,0 +1,22 @@
-- Deploy camper:invoice_product_amount to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: invoice_product_tax
begin;
set search_path to camper, public;
create or replace view invoice_product_amount as
select invoice_product_id
, round(price * quantity * (1 - discount_rate))::integer as subtotal
, max(round(price * quantity * (1 - discount_rate))::integer) + coalesce(sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer, 0) as total
from invoice_product
left join invoice_product_tax using (invoice_product_id)
group by invoice_product_id, price, quantity, discount_rate
;
grant select on table invoice_product_amount to employee;
grant select on table invoice_product_amount to admin;
commit;

View File

@ -0,0 +1,18 @@
-- Deploy camper:invoice_product_product to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: product
begin;
set search_path to camper;
create table invoice_product_product (
invoice_product_id integer primary key references invoice_product,
product_id integer not null references product
);
grant select, insert, update, delete on table invoice_product_product to employee;
grant select, insert, update, delete on table invoice_product_product to admin;
commit;

View File

@ -0,0 +1,33 @@
-- Deploy camper:invoice_product_tax to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: tax
-- requires: tax_rate
begin;
set search_path to camper, public;
create table invoice_product_tax (
invoice_product_id integer not null references invoice_product,
tax_id integer not null references tax,
tax_rate tax_rate not null,
primary key (invoice_product_id, tax_id)
);
grant select, insert, update, delete on table invoice_product_tax to employee;
grant select, insert, update, delete on table invoice_product_tax to admin;
alter table invoice_product_tax enable row level security;
create policy company_policy
on invoice_product_tax
using (
exists(
select 1
from invoice_product
where invoice_product.invoice_product_id = invoice_product_tax.invoice_product_id
)
);
commit;

16
deploy/invoice_status.sql Normal file
View File

@ -0,0 +1,16 @@
-- Deploy camper:invoice_status to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create table invoice_status (
invoice_status text primary key,
name text not null
);
grant select on table invoice_status to employee;
grant select on table invoice_status to admin;
commit;

View File

@ -0,0 +1,20 @@
-- Deploy camper:invoice_status_i18n to pg
-- requires: schema_camper
-- requires: invoice_status
-- requires: language
begin;
set search_path to camper, public;
create table invoice_status_i18n (
invoice_status text not null references invoice_status,
lang_tag text not null references language,
name text not null,
primary key (invoice_status, lang_tag)
);
grant select on table invoice_status_i18n to employee;
grant select on table invoice_status_i18n to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy camper:invoice_tax_amount to pg
-- requires: schema_camper
-- requires: invoice_product
-- requires: invoice_product_tax
begin;
set search_path to camper, public;
create or replace view invoice_tax_amount as
select invoice_id
, tax_id
, sum(round(round(price * quantity * (1 - discount_rate))::integer * tax_rate)::integer)::integer as amount
from invoice_product
join invoice_product_tax using (invoice_product_id)
group by invoice_id
, tax_id
;
grant select on table invoice_tax_amount to employee;
grant select on table invoice_tax_amount to admin;
commit;

View File

@ -0,0 +1,67 @@
-- Deploy camper:marshal_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: payment
-- requires: payment_customer
-- requires: payment_option
-- requires: payment__acsi_card
-- requires: payment_customer__-acsi_card
begin;
set search_path to camper, public;
create or replace function marshal_payment(pid integer) returns jsonb as
$$
select to_jsonb(ctx)
from (
select company_id
, campsite_type_id
, arrival_date
, departure_date
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, full_name
, address
, postal_code
, city
, country_code
, email
, phone
, lang_tag
, (
select array_agg(to_jsonb(o))
from (
select campsite_type_option_id
, units
, subtotal
from payment_option
where payment_option.payment_id = payment.payment_id
) o
) as options
from payment
join payment_customer using (payment_id)
where payment_id = pid
) as ctx;
$$
language sql
;
revoke execute on function marshal_payment(integer) from public;
grant execute on function marshal_payment(integer) to guest;
grant execute on function marshal_payment(integer) to employee;
grant execute on function marshal_payment(integer) to admin;
commit;

View File

@ -0,0 +1,14 @@
-- Deploy camper:new_invoice_amount to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create type new_invoice_amount as (
subtotal text,
taxes text[][],
total text
);
commit;

View File

@ -0,0 +1,19 @@
-- Deploy camper:new_invoice_product to pg
-- requires: schema_camper
-- requires: discount_rate
begin;
set search_path to camper, public;
create type new_invoice_product as (
product_id integer,
name text,
description text,
price text,
quantity integer,
discount_rate discount_rate,
tax integer[]
);
commit;

View File

@ -0,0 +1,38 @@
-- Deploy camper:next_invoice_number to pg
-- requires: schema_camper
-- requires: invoice_number_counter
begin;
set search_path to camper, public;
create or replace function next_invoice_number(company integer, invoice_date date) returns text
as
$$
declare
num integer;
invoice_number text;
begin
insert into invoice_number_counter (company_id, year, currval)
values (next_invoice_number.company, date_part('year', invoice_date), 1)
on conflict (company_id, year) do
update
set currval = invoice_number_counter.currval + 1
returning currval
into num;
select to_char(invoice_date, to_char(num, 'FM' || replace(invoice_number_format, '"', '\""')))
into invoice_number
from company
where company_id = next_invoice_number.company;
return invoice_number;
end;
$$
language plpgsql;
revoke execute on function next_invoice_number(integer, date) from public;
grant execute on function next_invoice_number(integer, date) to employee;
grant execute on function next_invoice_number(integer, date) to admin;
commit;

View File

@ -0,0 +1,22 @@
-- Deploy camper:payment__acsi_card to pg
-- requires: payment
begin;
set search_path to camper, public;
alter table payment
add column acsi_card boolean not null default false
;
update payment
set acsi_card = payment_customer.acsi_card
from payment_customer
where payment_customer.payment_id = payment.payment_id
;
alter table payment
alter column acsi_card drop default
;
commit;

View File

@ -0,0 +1,10 @@
-- Deploy camper:payment_customer__-acsi_card to pg
-- requires: payment__acsi_card
begin;
alter table camper.payment_customer
drop column if exists acsi_card
;
commit;

34
deploy/payment_method.sql Normal file
View File

@ -0,0 +1,34 @@
-- Deploy camper:payment_method to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
begin;
set search_path to camper, public;
create table payment_method (
payment_method_id integer generated by default as identity primary key,
company_id integer not null references company,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
instructions text not null
);
grant select, insert, update, delete on table payment_method to employee;
grant select, insert, update, delete on table payment_method to admin;
alter table payment_method enable row level security;
create policy company_policy
on payment_method
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = payment_method.company_id
)
);
commit;

40
deploy/product.sql Normal file
View File

@ -0,0 +1,40 @@
-- Deploy camper:product to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
begin;
set search_path to camper, public;
create table product (
product_id integer generated by default as identity primary key,
company_id integer not null references company,
slug uuid not null default gen_random_uuid(),
name text not null constraint name_not_empty check(length(trim(name)) > 0),
description text not null default '',
price integer not null,
created_at timestamptz not null default current_timestamp
);
comment on column product.price is
'Price is stored in cents.';
grant select, insert, update, delete on table product to employee;
grant select, insert, update, delete on table product to admin;
alter table product enable row level security;
create policy company_policy
on product
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = product.company_id
)
);
commit;

31
deploy/product_tax.sql Normal file
View File

@ -0,0 +1,31 @@
-- Deploy camper:product_tax to pg
-- requires: schema_camper
-- requires: product
-- requires: tax
begin;
set search_path to camper, public;
create table product_tax (
product_id integer not null references product,
tax_id integer not null references tax,
primary key (product_id, tax_id)
);
grant select, insert, update, delete on table product_tax to employee;
grant select, insert, update, delete on table product_tax to admin;
alter table product_tax enable row level security;
create policy company_policy
on product_tax
using (
exists(
select 1
from product
where product.product_id = product_tax.product_id
)
);
commit;

View File

@ -6,12 +6,16 @@
-- requires: country_code
-- requires: email
-- requires: extension_pg_libphonenumber
-- requires: payment__acsi_card
-- requires: payment_customer__-acsi_card
begin;
set search_path to camper, public;
create or replace function ready_payment(payment_slug uuid, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code country_code, customer_email email, customer_phone text, customer_lang_tag text, customer_acsi_card boolean) returns integer as
drop function if exists ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean);
create or replace function ready_payment(payment_slug uuid, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code country_code, customer_email email, customer_phone text, customer_lang_tag text) returns integer as
$$
declare
pid integer;
@ -28,8 +32,8 @@ begin
raise check_violation using message = 'insert or update on table "payment" violates check constraint "payment_is_draft"';
end if;
insert into payment_customer (payment_id, full_name, address, postal_code, city, country_code, email, phone, acsi_card, lang_tag)
values (pid, customer_name, customer_address, customer_post_code, customer_city, customer_country_code, customer_email, parse_packed_phone_number(customer_phone, customer_country_code), customer_acsi_card, customer_lang_tag)
insert into payment_customer (payment_id, full_name, address, postal_code, city, country_code, email, phone, lang_tag)
values (pid, customer_name, customer_address, customer_post_code, customer_city, customer_country_code, customer_email, parse_packed_phone_number(customer_phone, customer_country_code), customer_lang_tag)
on conflict (payment_id) do update
set full_name = excluded.full_name
, address = excluded.address
@ -38,7 +42,6 @@ begin
, country_code = excluded.country_code
, email = excluded.email
, phone = excluded.phone
, acsi_card = excluded.acsi_card
, lang_tag = excluded.lang_tag
;
@ -48,9 +51,9 @@ $$
language plpgsql
;
revoke execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) from public;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to guest;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to employee;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to admin;
revoke execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) from public;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) to guest;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) to employee;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text) to admin;
commit;

View File

@ -0,0 +1,56 @@
-- Deploy camper:ready_payment to pg
-- requires: roles
-- requires: schema_camper
-- requires: payment
-- requires: payment_customer
-- requires: country_code
-- requires: email
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
create or replace function ready_payment(payment_slug uuid, customer_name text, customer_address text, customer_post_code text, customer_city text, customer_country_code country_code, customer_email email, customer_phone text, customer_lang_tag text, customer_acsi_card boolean) returns integer as
$$
declare
pid integer;
begin
update payment
set payment_status = 'pending'
, updated_at = current_timestamp
where slug = payment_slug
and payment_status = 'draft'
returning payment_id into pid
;
if pid is null then
raise check_violation using message = 'insert or update on table "payment" violates check constraint "payment_is_draft"';
end if;
insert into payment_customer (payment_id, full_name, address, postal_code, city, country_code, email, phone, acsi_card, lang_tag)
values (pid, customer_name, customer_address, customer_post_code, customer_city, customer_country_code, customer_email, parse_packed_phone_number(customer_phone, customer_country_code), customer_acsi_card, customer_lang_tag)
on conflict (payment_id) do update
set full_name = excluded.full_name
, address = excluded.address
, postal_code = excluded.postal_code
, city = excluded.city
, country_code = excluded.country_code
, email = excluded.email
, phone = excluded.phone
, acsi_card = excluded.acsi_card
, lang_tag = excluded.lang_tag
;
return pid;
end;
$$
language plpgsql
;
revoke execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) from public;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to guest;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to employee;
grant execute on function ready_payment(uuid, text, text, text, text, country_code, email, text, text, boolean) to admin;
commit;

View File

@ -0,0 +1,11 @@
-- Deploy camper:season_calendar_season_id_fkey to pg
-- requires: season
-- requires: season_calendar
begin;
alter table camper.season_calendar
add constraint season_calendar_season_id_fkey foreign key (season_id) references camper.season (season_id)
;
commit;

17
deploy/sex.sql Normal file
View File

@ -0,0 +1,17 @@
-- Deploy camper:sex to pg
-- requires: roles
-- requires: schema_camper
begin;
set search_path to camper, public;
create table sex (
sex_id varchar(1) primary key,
name text not null
);
grant select on table sex to employee;
grant select on table sex to admin;
commit;

21
deploy/sex_i18n.sql Normal file
View File

@ -0,0 +1,21 @@
-- Deploy camper:sex_i18n to pg
-- requires: roles
-- requires: schema_camper
-- requires: sex
-- requires: language
begin;
set search_path to camper, public;
create table sex_i18n (
sex_id varchar(1) not null references sex,
lang_tag text not null references language,
name text not null,
primary key (sex_id, lang_tag)
);
grant select on sex_i18n to employee;
grant select on sex_i18n to admin;
commit;

37
deploy/tax.sql Normal file
View File

@ -0,0 +1,37 @@
-- Deploy camper:tax to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
-- requires: tax_rate
-- requires: tax_class
begin;
set search_path to camper, public;
create table tax (
tax_id integer generated by default as identity primary key,
company_id integer not null references company,
tax_class_id integer not null references tax_class,
name text not null constraint name_not_empty check(length(trim(name)) > 0),
rate tax_rate not null
);
grant select, insert, update, delete on table tax to employee;
grant select, insert, update, delete on table tax to admin;
alter table tax enable row level security;
create policy company_policy
on tax
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = tax.company_id
)
);
commit;

33
deploy/tax_class.sql Normal file
View File

@ -0,0 +1,33 @@
-- Deploy camper:tax_class to pg
-- requires: roles
-- requires: schema_camper
-- requires: user_profile
-- requires: company
begin;
set search_path to camper, public;
create table tax_class (
tax_class_id integer generated by default as identity not null primary key,
company_id integer not null references company,
name text not null constraint name_not_empty check(length(trim(name)) > 0)
);
grant select, insert, update, delete on table tax_class to employee;
grant select, insert, update, delete on table tax_class to admin;
alter table tax_class enable row level security;
create policy company_policy
on tax_class
using (
exists(
select 1
from company_user
join user_profile using (user_id)
where company_user.company_id = tax_class.company_id
)
);
commit;

14
deploy/tax_rate.sql Normal file
View File

@ -0,0 +1,14 @@
-- Deploy camper:tax_rate to pg
-- requires: schema_camper
begin;
set search_path to camper, public;
create domain tax_rate as numeric
check (value > -1 and value < 1);
comment on domain tax_rate is
'A rate for taxes in the range (-1, 1)';
commit;

View File

@ -0,0 +1,100 @@
-- Deploy camper:unmarshal_booking to pg
-- requires: roles
-- requires: schema_camper
-- requires: booking
-- requires: booking_option
-- requires: extension_pg_libphonenumber
begin;
set search_path to camper, public;
grant select, insert on table booking to guest;
grant select, insert on table booking_option to guest;
drop policy if exists delete_from_company on booking;
drop policy if exists update_company on booking;
drop policy if exists insert_to_company on booking;
drop policy if exists select_from_company on booking;
alter table booking disable row level security;
create or replace function unmarshal_booking(data jsonb) returns integer as
$$
declare
bid integer;
begin
insert into booking
( company_id
, campsite_type_id
, stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, currency_code
, zone_preferences
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, lang_tag
)
values((data->>'company_id')::integer
, (data->>'campsite_type_id')::integer
, daterange((data->>'arrival_date')::date, (data->>'departure_date')::date)
, (data->>'subtotal_nights')::integer
, (data->>'number_adults')::integer
, (data->>'subtotal_adults')::integer
, (data->>'number_teenagers')::integer
, (data->>'subtotal_teenagers')::integer
, (data->>'number_children')::integer
, (data->>'subtotal_children')::integer
, (data->>'number_dogs')::integer
, (data->>'subtotal_dogs')::integer
, (data->>'subtotal_tourist_tax')::integer
, (data->>'total')::integer
, data->>'currency_code'
, data->>'zone_preferences'
, (data->>'acsi_card')::boolean
, data->>'full_name'
, data->>'address'
, data->>'postal_code'
, data->>'city'
, data->>'country_code'
, data->>'email'
, parse_packed_phone_number(data->>'phone', data->>'country_code')
, data->>'lang_tag'
)
returning booking_id into bid;
if jsonb_typeof(data->'options') = 'array' then
insert into booking_option (booking_id, campsite_type_option_id, units, subtotal)
select bid, campsite_type_option_id, units, subtotal
from jsonb_to_recordset(data->'options') as x(campsite_type_option_id integer, units integer, subtotal integer)
;
end if;
return bid;
end;
$$
language plpgsql
;
revoke execute on function unmarshal_booking(jsonb) from public;
grant execute on function unmarshal_booking(jsonb) to guest;
grant execute on function unmarshal_booking(jsonb) to employee;
grant execute on function unmarshal_booking(jsonb) to admin;
commit;

View File

@ -0,0 +1,23 @@
-- Deploy camper:weather_forecast to pg
-- requires: schema_camper
-- requires: roles
-- requires: extension_uri
begin;
set search_path to camper, public;
create table weather_forecast (
station_uri uri primary key,
weather_condition_id text not null,
day_temperature numeric(5,2) not null,
min_temperature numeric(5,2) not null,
forecasted_at timestamp with time zone not null,
updated_at timestamp with time zone not null
);
grant select on table weather_forecast to guest;
grant select on table weather_forecast to employee;
grant select, insert, update, delete on table weather_forecast to admin;
commit;

3
go.mod
View File

@ -4,15 +4,16 @@ go 1.19
require (
github.com/jackc/pgconn v1.11.0
github.com/jackc/pgio v1.0.0
github.com/jackc/pgtype v1.10.0
github.com/jackc/pgx/v4 v4.15.0
github.com/leonelquinteros/gotext v1.5.0
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8
golang.org/x/text v0.7.0
)
require (
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect

2
go.sum
View File

@ -88,6 +88,8 @@ github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8 h1:iZTHFqK/oFrjyFDkiw5U/RjQxkMlkpq6tHQIO407i+s=
github.com/rainycape/unidecode v0.0.0-20150906181237-c9cf8cdbbfe8/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=

View File

@ -13,9 +13,11 @@ import (
"dev.tandem.ws/tandem/camper/pkg/booking"
"dev.tandem.ws/tandem/camper/pkg/campsite"
"dev.tandem.ws/tandem/camper/pkg/company"
"dev.tandem.ws/tandem/camper/pkg/customer"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/home"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/invoice"
"dev.tandem.ws/tandem/camper/pkg/legal"
"dev.tandem.ws/tandem/camper/pkg/location"
"dev.tandem.ws/tandem/camper/pkg/media"
@ -32,11 +34,14 @@ type adminHandler struct {
booking *booking.AdminHandler
campsite *campsite.AdminHandler
company *company.AdminHandler
customer *customer.AdminHandler
home *home.AdminHandler
invoice *invoice.AdminHandler
legal *legal.AdminHandler
location *location.AdminHandler
media *media.AdminHandler
payment *payment.AdminHandler
prebooking *booking.PrebookingHandler
season *season.AdminHandler
services *services.AdminHandler
surroundings *surroundings.AdminHandler
@ -49,11 +54,14 @@ func newAdminHandler(mediaDir string) *adminHandler {
booking: booking.NewAdminHandler(),
campsite: campsite.NewAdminHandler(),
company: company.NewAdminHandler(),
customer: customer.NewAdminHandler(),
home: home.NewAdminHandler(),
invoice: invoice.NewAdminHandler(),
legal: legal.NewAdminHandler(),
location: location.NewAdminHandler(),
media: media.NewAdminHandler(mediaDir),
payment: payment.NewAdminHandler(),
prebooking: booking.NewPrebookingHandler(),
season: season.NewAdminHandler(),
services: services.NewAdminHandler(),
surroundings: surroundings.NewAdminHandler(),
@ -85,16 +93,22 @@ func (h *adminHandler) Handle(user *auth.User, company *auth.Company, conn *data
h.campsite.Handler(user, company, conn).ServeHTTP(w, r)
case "company":
h.company.Handler(user, company, conn).ServeHTTP(w, r)
case "customers":
h.customer.Handler(user, company, conn).ServeHTTP(w, r)
case "home":
h.home.Handler(user, company, conn).ServeHTTP(w, r)
case "legal":
h.legal.Handler(user, company, conn).ServeHTTP(w, r)
case "invoices":
h.invoice.Handler(user, company, conn).ServeHTTP(w, r)
case "location":
h.location.Handler(user, company, conn).ServeHTTP(w, r)
case "media":
h.media.Handler(user, company, conn).ServeHTTP(w, r)
case "payments":
h.payment.Handler(user, company, conn).ServeHTTP(w, r)
case "prebookings":
h.prebooking.Handler(user, company, conn).ServeHTTP(w, r)
case "seasons":
h.season.Handler(user, company, conn).ServeHTTP(w, r)
case "services":

View File

@ -7,15 +7,23 @@ package booking
import (
"context"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/ods"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"golang.org/x/text/language"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
"dev.tandem.ws/tandem/camper/pkg/uuid"
)
type AdminHandler struct {
@ -35,6 +43,91 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
switch r.Method {
case http.MethodGet:
serveBookingIndex(w, r, user, company, conn)
case http.MethodPost:
addBooking(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "new":
switch r.Method {
case http.MethodGet:
serveAdminBookingForm(w, r, user, company, conn, 0, "/admin/bookings/new")
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
if !uuid.Valid(head) {
http.NotFound(w, r)
return
}
h.bookingHandler(user, company, conn, head).ServeHTTP(w, r)
}
})
}
func serveAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, id int, url string) {
f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil {
panic(err)
}
f.ID = id
f.URL = url
f.MustRender(w, r, user, company)
}
func (h *AdminHandler) bookingHandler(user *auth.User, company *auth.Company, conn *database.Conn, slug string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
if err := r.ParseForm(); err != nil {
panic(err)
}
if len(r.Form) > 0 {
// Act as if it was a new form, because everything needed to render form fields is
// already passed as in request query.
id, err := conn.GetInt(r.Context(), "select booking_id from booking where slug = $1", slug)
if err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
serveAdminBookingForm(w, r, user, company, conn, id, "/admin/bookings/"+slug)
return
}
f := newEmptyAdminBookingForm(r.Context(), conn, company, user.Locale)
if err := f.FillFromDatabase(r.Context(), conn, company, slug, user.Locale); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
f.MustRender(w, r, user, company)
case http.MethodPut:
updateBooking(w, r, user, company, conn, slug)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
case "check-in":
switch r.Method {
case http.MethodGet:
serveCheckInForm(w, r, user, company, conn, slug)
case http.MethodPost:
checkInBooking(w, r, user, company, conn, slug)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "guest":
switch r.Method {
case http.MethodGet:
serveGuestForm(w, r, user, company, conn, slug)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
@ -45,28 +138,41 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
}
func serveBookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language)
filters := newFilterForm(r.Context(), conn, company, user.Locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language, filters)
if err != nil {
panic(err)
}
page := bookingIndex(bookings)
page := &bookingIndex{
Bookings: filters.buildCursor(bookings),
Filters: filters,
}
page.MustRender(w, r, user, company)
}
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag) ([]*bookingEntry, error) {
rows, err := conn.Query(ctx, `
select left(slug::text, 10)
, '/admin/booking/' || slug
, arrival_date
, departure_date
func collectBookingEntries(ctx context.Context, conn *database.Conn, lang language.Tag, filters *filterForm) ([]*bookingEntry, error) {
where, args := filters.BuildQuery([]interface{}{lang.String()})
rows, err := conn.Query(ctx, fmt.Sprintf(`
select booking_id
, left(slug::text, 10)
, '/admin/bookings/' || slug
, lower(stay)
, upper(stay)
, holder_name
, booking.booking_status
, coalesce(i18n.name, status.name)
from booking
join booking_status as status using (booking_status)
left join booking_status_i18n as i18n on status.booking_status = i18n.booking_status and i18n.lang_tag = $1
order by arrival_date desc
`, lang)
where (%s)
order by lower(stay) desc
, booking_id desc
LIMIT %d
`, where, filters.PerPage()+1), args...)
if err != nil {
return nil, err
}
@ -75,7 +181,7 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
var entries []*bookingEntry
for rows.Next() {
entry := &bookingEntry{}
if err = rows.Scan(&entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil {
if err = rows.Scan(&entry.ID, &entry.Reference, &entry.URL, &entry.ArrivalDate, &entry.DepartureDate, &entry.HolderName, &entry.Status, &entry.StatusLabel); err != nil {
return nil, err
}
entries = append(entries, entry)
@ -85,6 +191,7 @@ func collectBookingEntries(ctx context.Context, conn *database.Conn, lang langua
}
type bookingEntry struct {
ID int
Reference string
URL string
ArrivalDate time.Time
@ -94,7 +201,10 @@ type bookingEntry struct {
StatusLabel string
}
type bookingIndex []*bookingEntry
type bookingIndex struct {
Bookings []*bookingEntry
Filters *filterForm
}
func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
switch r.URL.Query().Get("format") {
@ -106,16 +216,16 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
"Holder Name",
"Status",
}
ods, err := writeTableOds(page, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error {
if err := writeCellString(sb, entry.Reference); err != nil {
table, err := ods.WriteTable(page.Bookings, columns, user.Locale, func(sb *strings.Builder, entry *bookingEntry) error {
if err := ods.WriteCellString(sb, entry.Reference); err != nil {
return err
}
writeCellDate(sb, entry.ArrivalDate)
writeCellDate(sb, entry.DepartureDate)
if err := writeCellString(sb, entry.HolderName); err != nil {
ods.WriteCellDate(sb, entry.ArrivalDate)
ods.WriteCellDate(sb, entry.DepartureDate)
if err := ods.WriteCellString(sb, entry.HolderName); err != nil {
return err
}
if err := writeCellString(sb, entry.StatusLabel); err != nil {
if err := ods.WriteCellString(sb, entry.StatusLabel); err != nil {
return err
}
return nil
@ -123,8 +233,432 @@ func (page bookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user
if err != nil {
panic(err)
}
mustWriteOdsResponse(w, ods, user.Locale.Pgettext("bookings.ods", "filename"))
ods.MustWriteResponse(w, table, user.Locale.Pgettext("bookings.ods", "filename"))
default:
template.MustRenderAdmin(w, r, user, company, "booking/index.gohtml", page)
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "booking/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "booking/index.gohtml", "booking/results.gohtml")
}
}
}
type adminBookingForm struct {
*bookingForm
ID int
URL string
Status string
Campsites []*CampsiteEntry
selected []int
Months []*Month
Error error
}
func newEmptyAdminBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *adminBookingForm {
return &adminBookingForm{
bookingForm: newEmptyBookingForm(ctx, conn, company, l),
}
}
func newAdminBookingForm(r *http.Request, conn *database.Conn, company *auth.Company, l *locale.Locale) (*adminBookingForm, error) {
inner, err := newBookingForm(r, company, conn, l)
if err != nil {
return nil, err
}
if inner.Options != nil {
for _, option := range inner.Options.Options {
option.Subtotal = findSubtotal(option.ID, inner.Cart)
}
}
f := &adminBookingForm{
bookingForm: inner,
}
// Dates and Campsite are valid
if inner.Guests != nil {
selected := r.Form["campsite"]
if err = f.FetchCampsites(r.Context(), conn, company, selected); err != nil {
return nil, err
}
}
return f, nil
}
func (f *adminBookingForm) FetchCampsites(ctx context.Context, conn *database.Conn, company *auth.Company, selected []string) error {
arrivalDate, _ := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
from := arrivalDate.AddDate(0, 0, -1)
departureDate, _ := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
to := departureDate.AddDate(0, 0, 2)
f.Months = CollectMonths(from, to)
var err error
f.Campsites, err = CollectCampsiteEntries(ctx, company, conn, from, to, f.CampsiteType.String())
if err != nil {
return err
}
for _, s := range selected {
ID, _ := strconv.Atoi(s)
for _, c := range f.Campsites {
if c.ID == ID {
f.selected = append(f.selected, c.ID)
c.Selected = true
break
}
}
}
return nil
}
func findSubtotal(ID int, cart *bookingCart) string {
none := "0.0"
if cart == nil || cart.Draft == nil {
return none
}
for _, option := range cart.Draft.Options {
if option.ID == ID {
return option.Subtotal
}
}
return none
}
func (f *adminBookingForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) {
template.MustRenderAdminNoLayoutFiles(w, r, user, company, f, "booking/fields.gohtml", "booking/grid.gohtml")
} else {
template.MustRenderAdminFiles(w, r, user, company, f, "booking/form.gohtml", "booking/fields.gohtml", "booking/grid.gohtml")
}
}
func addBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
processAdminBookingForm(w, r, user, company, conn, 0, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
var err error
f.ID, err = tx.AddBookingFromPayment(ctx, f.PaymentSlug.Val)
if err != nil {
return err
}
return tx.EditBooking(
ctx,
f.ID,
f.Customer.FullName.Val,
f.Customer.Address.Val,
f.Customer.PostalCode.Val,
f.Customer.City.Val,
f.Customer.Country.String(),
f.Customer.Email.Val,
f.Customer.Phone.Val,
language.Make("und"),
"confirmed",
f.selected,
)
})
}
func updateBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
var bookingID int
var bookingStatus string
var langTag string
row := conn.QueryRow(r.Context(), "select booking_id, booking_status, lang_tag from booking where slug = $1", slug)
if err := row.Scan(&bookingID, &bookingStatus, &langTag); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(r.Form["cancel"]) > 0 {
if err := conn.CancelBooking(r.Context(), bookingID); err != nil {
panic(err)
}
if bookingStatus == "created" {
httplib.Redirect(w, r, "/admin/prebookings", http.StatusSeeOther)
} else {
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
return
}
processAdminBookingForm(w, r, user, company, conn, bookingID, func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error {
var err error
_, err = tx.EditBookingFromPayment(ctx, slug, f.PaymentSlug.Val)
if err != nil {
return err
}
if bookingStatus == "created" {
bookingStatus = "confirmed"
}
return tx.EditBooking(
ctx,
f.ID,
f.Customer.FullName.Val,
f.Customer.Address.Val,
f.Customer.PostalCode.Val,
f.Customer.City.Val,
f.Customer.Country.String(),
f.Customer.Email.Val,
f.Customer.Phone.Val,
language.Make(langTag),
bookingStatus,
f.selected,
)
})
}
func processAdminBookingForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, bookingID int, act func(ctx context.Context, tx *database.Tx, f *adminBookingForm) error) {
f, err := newAdminBookingForm(r, conn, company, user.Locale)
if err != nil {
panic(err)
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
f.ID = bookingID
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
defer tx.Rollback(r.Context())
if err := act(r.Context(), tx, f); err != nil {
panic(err)
}
tx.MustCommit(r.Context())
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
func (f *adminBookingForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
if f.Dates == nil {
return false, errors.New("no booking date fields")
}
if f.Guests == nil {
return false, errors.New("no guests fields")
}
if f.Customer == nil {
return false, errors.New("no customer fields")
}
if f.Cart == nil {
return false, errors.New("no booking cart")
}
v.CheckSelectedOptions(f.CampsiteType, l.GettextNoop("Selected campsite type is not valid."))
f.Dates.Valid(v, l)
f.Guests.Valid(v, l)
if f.Options != nil {
f.Options.Valid(v, l)
}
var country string
if f.Customer.Country.ValidOptionsSelected() {
country = f.Customer.Country.Selected[0]
}
if v.CheckRequired(f.Customer.FullName, l.GettextNoop("Full name can not be empty.")) {
v.CheckMinLength(f.Customer.FullName, 1, l.GettextNoop("Full name must have at least one letter."))
}
if f.Customer.PostalCode.Val != "" {
if country == "" {
v.Check(f.Customer.PostalCode, false, l.GettextNoop("Country can not be empty to validate the postcode."))
} else if _, err := v.CheckValidPostalCode(ctx, conn, f.Customer.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil {
return false, err
}
}
if f.Customer.Email.Val != "" {
v.CheckValidEmail(f.Customer.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
if f.Customer.Phone.Val != "" {
if country == "" {
v.Check(f.Customer.Phone, false, l.GettextNoop("Country can not be empty to validate the phone."))
} else if _, err := v.CheckValidPhone(ctx, conn, f.Customer.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
if len(f.selected) == 0 {
f.Error = errors.New(l.Gettext("You must select at least one accommodation."))
v.AllOK = false
} else if f.Dates.ArrivalDate.Error == nil && f.Dates.DepartureDate.Error == nil {
if available, err := datesAvailable(ctx, conn, f.ID, f.Dates, f.selected); err != nil {
return false, err
} else if !available {
f.Error = errors.New(l.Gettext("The selected accommodations have no available openings in the requested dates."))
v.AllOK = false
}
}
return v.AllOK, nil
}
func datesAvailable(ctx context.Context, conn *database.Conn, bookingID int, dates *DateFields, selectedCampsites []int) (bool, error) {
return conn.GetBool(ctx, `
select not exists (
select 1
from camper.booking_campsite
where booking_id <> $1
and campsite_id = any ($4)
and stay && daterange($2::date, $3::date)
)
`,
bookingID,
dates.ArrivalDate,
dates.DepartureDate,
selectedCampsites,
)
}
func (f *adminBookingForm) FillFromDatabase(ctx context.Context, conn *database.Conn, company *auth.Company, slug string, l *locale.Locale) error {
f.Cart = &bookingCart{Draft: &paymentDraft{}}
f.Customer = newBookingCustomerFields(ctx, conn, l)
var arrivalDate string
var departureDate string
var acsiCard bool
var zonePreferences string
var selected []string
row := conn.QueryRow(ctx, `
select booking_id
, '/admin/bookings/' || booking.slug
, booking_status
, array[campsite_type.slug::text]
, lower(booking.stay)::text
, upper(booking.stay)::text
, upper(booking.stay) - lower(booking.stay)
, to_price(subtotal_nights, decimal_digits)
, number_adults
, to_price(subtotal_adults, decimal_digits)
, number_teenagers
, to_price(subtotal_teenagers, decimal_digits)
, number_children
, to_price(subtotal_children, decimal_digits)
, number_dogs
, to_price(subtotal_dogs, decimal_digits)
, to_price(subtotal_tourist_tax, decimal_digits)
, to_price(total, decimal_digits)
, acsi_card
, holder_name
, coalesce(address, '')
, coalesce(postal_code, '')
, coalesce(city, '')
, array[coalesce(country_code::text, '')]
, coalesce(email::text, '')
, coalesce(phone::text, '')
, zone_preferences
, array_agg(coalesce(campsite_id::text, ''))
from booking
join campsite_type using (campsite_type_id)
left join campsite_type_pet_cost as pet using (campsite_type_id)
left join booking_campsite using (booking_id)
join currency using (currency_code)
where booking.slug = $1
group by booking_id
, campsite_type.slug
, booking.stay
, subtotal_nights
, number_adults
, subtotal_adults
, number_teenagers
, subtotal_teenagers
, number_children
, subtotal_children
, number_dogs
, subtotal_dogs
, subtotal_tourist_tax
, total
, acsi_card
, holder_name
, address
, postal_code
, city
, country_code
, email
, phone
, zone_preferences
, decimal_digits
`, slug)
if err := row.Scan(
&f.ID,
&f.URL,
&f.Status,
&f.CampsiteType.Selected,
&arrivalDate,
&departureDate,
&f.Cart.Draft.NumNights,
&f.Cart.Draft.Nights,
&f.Cart.Draft.NumAdults,
&f.Cart.Draft.Adults,
&f.Cart.Draft.NumTeenagers,
&f.Cart.Draft.Teenagers,
&f.Cart.Draft.NumChildren,
&f.Cart.Draft.Children,
&f.Cart.Draft.NumDogs,
&f.Cart.Draft.Dogs,
&f.Cart.Draft.TouristTax,
&f.Cart.Draft.Total,
&acsiCard,
&f.Customer.FullName.Val,
&f.Customer.Address.Val,
&f.Customer.PostalCode.Val,
&f.Customer.City.Val,
&f.Customer.Country.Selected,
&f.Customer.Email.Val,
&f.Customer.Phone.Val,
&zonePreferences,
&selected,
); err != nil {
return err
}
var err error
f.Dates, err = NewDateFields(ctx, conn, f.CampsiteType.String())
if err != nil {
return err
}
f.Dates.ArrivalDate.Val = arrivalDate
f.Dates.DepartureDate.Val = departureDate
f.Dates.AdjustValues(l)
f.Guests, err = newBookingGuestFields(ctx, conn, f.CampsiteType.String(), arrivalDate, departureDate)
if err != nil {
return err
}
f.Guests.NumberAdults.Val = strconv.Itoa(f.Cart.Draft.NumAdults)
f.Guests.NumberTeenagers.Val = strconv.Itoa(f.Cart.Draft.NumTeenagers)
f.Guests.NumberChildren.Val = strconv.Itoa(f.Cart.Draft.NumChildren)
if f.Guests.NumberDogs != nil {
f.Guests.NumberDogs.Val = strconv.Itoa(f.Cart.Draft.NumDogs)
}
if f.Guests.ACSICard != nil {
f.Guests.ACSICard.Checked = acsiCard
}
f.Guests.AdjustValues(f.Cart.Draft.NumAdults+f.Cart.Draft.NumTeenagers+f.Cart.Draft.NumChildren, l)
f.Options, err = newBookingOptionFields(ctx, conn, f.CampsiteType.String(), l)
if err != nil {
return err
}
if f.Options != nil {
if f.Options.ZonePreferences != nil {
f.Options.ZonePreferences.Val = zonePreferences
}
if err = f.Options.FillFromDatabase(ctx, conn, f.ID); err != nil {
return err
}
}
if err = f.FetchCampsites(ctx, conn, company, selected); err != nil {
return err
}
return nil
}

162
pkg/booking/campsite.go Normal file
View File

@ -0,0 +1,162 @@
package booking
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"time"
"dev.tandem.ws/tandem/camper/pkg/season"
)
type Month struct {
Year int
Month time.Month
Name string
Days []time.Time
Spans []*Span
}
type Span struct {
Weekend bool
Today bool
Count int
}
func isWeekend(t time.Time) bool {
switch t.Weekday() {
case time.Saturday, time.Sunday:
return true
default:
return false
}
}
func CollectMonths(from time.Time, to time.Time) []*Month {
current := time.Date(from.Year(), from.Month(), from.Day(), 0, 0, 0, 0, time.UTC)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
var months []*Month
for !current.Equal(to) {
span := &Span{
Weekend: isWeekend(current),
Today: current.Equal(today),
}
month := &Month{
Year: current.Year(),
Month: current.Month(),
Name: season.LongMonthNames[current.Month()-1],
Days: make([]time.Time, 0, 31),
Spans: make([]*Span, 0, 10),
}
month.Spans = append(month.Spans, span)
for current.Month() == month.Month && !current.Equal(to) {
month.Days = append(month.Days, current)
if span.Weekend != isWeekend(current) || span.Today != current.Equal(today) {
span = &Span{
Weekend: isWeekend(current),
Today: current.Equal(today),
}
month.Spans = append(month.Spans, span)
}
span.Count = span.Count + 1
current = current.AddDate(0, 0, 1)
}
months = append(months, month)
}
return months
}
type CampsiteEntry struct {
ID int
Label string
Type string
TypeSlug string
Active bool
Selected bool
Bookings map[time.Time]*CampsiteBooking
}
type CampsiteBooking struct {
URL string
Holder string
Status string
Nights int
Begin bool
End bool
}
func CollectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsiteType string) ([]*CampsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite_id
, campsite.label
, campsite_type.name
, campsite_type.slug
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
and ($2::uuid is null or campsite_type.slug = $2::uuid)
order by label`, company.ID, database.ZeroNullUUID(campsiteType))
if err != nil {
return nil, err
}
defer rows.Close()
byLabel := make(map[string]*CampsiteEntry)
var campsites []*CampsiteEntry
for rows.Next() {
entry := &CampsiteEntry{}
if err = rows.Scan(&entry.ID, &entry.Label, &entry.Type, &entry.TypeSlug, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
byLabel[entry.Label] = entry
}
if err := collectCampsiteBookings(ctx, company, conn, from, to, byLabel); err != nil {
return nil, err
}
return campsites, nil
}
func collectCampsiteBookings(ctx context.Context, company *auth.Company, conn *database.Conn, from time.Time, to time.Time, campsites map[string]*CampsiteEntry) error {
rows, err := conn.Query(ctx, `
select campsite.label
, lower(booking_campsite.stay * daterange($2::date, $3::date))
, '/admin/bookings/' || booking.slug
, holder_name
, booking_status
, upper(booking_campsite.stay * daterange($2::date, $3::date)) - lower(booking_campsite.stay * daterange($2::date, $3::date))
, booking_campsite.stay &> daterange($2::date, $3::date)
, booking_campsite.stay &< daterange($2::date, ($3 - 1)::date)
from booking_campsite
join booking using (booking_id)
join campsite using (campsite_id)
where booking.company_id = $1
and booking_campsite.stay && daterange($2::date, $3::date)
order by label`, company.ID, from, to)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
entry := &CampsiteBooking{}
var label string
var date time.Time
if err = rows.Scan(&label, &date, &entry.URL, &entry.Holder, &entry.Status, &entry.Nights, &entry.Begin, &entry.End); err != nil {
return err
}
campsite := campsites[label]
if campsite != nil {
if campsite.Bookings == nil {
campsite.Bookings = make(map[time.Time]*CampsiteBooking)
}
campsite.Bookings[date] = entry
}
}
return nil
}

View File

@ -10,6 +10,7 @@ import (
)
type bookingCart struct {
Draft *paymentDraft
Lines []*cartLine
Total string
DownPayment string
@ -23,49 +24,79 @@ type cartLine struct {
Subtotal string
}
func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*bookingCart, error) {
cart := &bookingCart{
Total: "0.0",
}
type paymentDraft struct {
NumAdults int
NumTeenagers int
NumChildren int
NumDogs int
NumNights int
Nights string
Adults string
Teenagers string
Children string
Dogs string
TouristTax string
Total string
DownPaymentPercent int
DownPayment string
Options []*paymentOption
}
type paymentOption struct {
ID int
Label string
Units int
Subtotal string
}
func draftPayment(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*paymentDraft, error) {
if f.Dates == nil {
return cart, nil
}
arrivalDate, err := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
if err != nil {
return cart, nil
}
departureDate, err := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
if err != nil {
return cart, nil
return nil, nil
}
if f.Guests == nil {
return cart, nil
return nil, nil
}
numAdults, err := strconv.Atoi(f.Guests.NumberAdults.Val)
arrivalDate, err := time.Parse(database.ISODateFormat, f.Dates.ArrivalDate.Val)
if err != nil {
return cart, nil
return nil, nil
}
numTeenagers, err := strconv.Atoi(f.Guests.NumberTeenagers.Val)
departureDate, err := time.Parse(database.ISODateFormat, f.Dates.DepartureDate.Val)
if err != nil {
return cart, nil
return nil, nil
}
numChildren, err := strconv.Atoi(f.Guests.NumberChildren.Val)
draft := &paymentDraft{}
draft.NumAdults, err = strconv.Atoi(f.Guests.NumberAdults.Val)
if err != nil {
return cart, nil
return nil, nil
}
draft.NumTeenagers, err = strconv.Atoi(f.Guests.NumberTeenagers.Val)
if err != nil {
return nil, nil
}
draft.NumChildren, err = strconv.Atoi(f.Guests.NumberChildren.Val)
if err != nil {
return nil, nil
}
numDogs := 0
if f.Guests.NumberDogs != nil {
numDogs, err = strconv.Atoi(f.Guests.NumberDogs.Val)
draft.NumDogs, err = strconv.Atoi(f.Guests.NumberDogs.Val)
if err != nil {
return cart, nil
return nil, nil
}
}
zonePreferences := ""
var zonePreferences string
if f.Options != nil && f.Options.ZonePreferences != nil {
zonePreferences = f.Options.ZonePreferences.Val
}
var acsiCard bool
if f.Guests.ACSICard != nil {
acsiCard = f.Guests.ACSICard.Checked
}
optionMap := make(map[int]*campsiteTypeOption)
var typeOptions []*campsiteTypeOption
if f.Options != nil {
@ -97,65 +128,42 @@ func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, ca
, to_price(total, decimal_digits)
, to_price(payment.down_payment, decimal_digits)
, (payment.down_payment_percent * 100)::int
from draft_payment($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) as payment
from draft_payment($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) as payment
join currency using (currency_code)
`,
database.ZeroNullUUID(f.PaymentSlug.Val),
arrivalDate,
departureDate,
campsiteType,
numAdults,
numTeenagers,
numChildren,
numDogs,
draft.NumAdults,
draft.NumTeenagers,
draft.NumChildren,
draft.NumDogs,
zonePreferences,
acsiCard,
database.OptionUnitsArray(optionUnits),
)
var paymentID int
var numNights int
var nights string
var adults string
var teenagers string
var children string
var dogs string
var touristTax string
var total string
var downPayment string
if err = row.Scan(
&f.PaymentSlug.Val,
&paymentID,
&numNights,
&nights,
&adults,
&teenagers,
&children,
&dogs,
&touristTax,
&total,
&downPayment,
&cart.DownPaymentPercent,
&draft.NumNights,
&draft.Nights,
&draft.Adults,
&draft.Teenagers,
&draft.Children,
&draft.Dogs,
&draft.TouristTax,
&draft.Total,
&draft.DownPayment,
&draft.DownPaymentPercent,
); err != nil {
if database.ErrorIsNotFound(err) {
return cart, nil
return nil, nil
}
return nil, err
}
maybeAddLine := func(units int, subtotal string, concept string) {
if units > 0 && subtotal != "" {
cart.Lines = append(cart.Lines, &cartLine{
Concept: concept,
Units: units,
Subtotal: subtotal,
})
}
}
maybeAddLine(numNights, nights, locale.PgettextNoop("Night", "cart"))
maybeAddLine(numAdults, adults, locale.PgettextNoop("Adult", "cart"))
maybeAddLine(numTeenagers, teenagers, locale.PgettextNoop("Teenager", "cart"))
maybeAddLine(numChildren, children, locale.PgettextNoop("Child", "cart"))
maybeAddLine(numDogs, dogs, locale.PgettextNoop("Dog", "cart"))
rows, err := conn.Query(ctx, `
select campsite_type_option_id
, units
@ -164,6 +172,7 @@ func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, ca
join payment using (payment_id)
join currency using (currency_code)
where payment_id = $1
order by campsite_type_option_id
`, paymentID)
if err != nil {
return nil, err
@ -182,20 +191,62 @@ func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, ca
if option == nil {
continue
}
maybeAddLine(units, subtotal, option.Label)
draft.Options = append(draft.Options, &paymentOption{
ID: option.ID,
Label: option.Label,
Units: units,
Subtotal: subtotal,
})
}
if rows.Err() != nil {
return nil, rows.Err()
}
maybeAddLine(numAdults, touristTax, locale.PgettextNoop("Tourist tax", "cart"))
return draft, nil
}
if total != "0.0" {
cart.Total = total
func newBookingCart(ctx context.Context, conn *database.Conn, f *bookingForm, campsiteType string) (*bookingCart, error) {
cart := &bookingCart{
Total: "0.0",
}
draft, err := draftPayment(ctx, conn, f, campsiteType)
if err != nil {
return nil, err
}
if draft == nil {
return cart, nil
}
cart.Draft = draft
cart.DownPaymentPercent = draft.DownPaymentPercent
maybeAddLine := func(units int, subtotal string, concept string) {
if units > 0 && subtotal != "" {
cart.Lines = append(cart.Lines, &cartLine{
Concept: concept,
Units: units,
Subtotal: subtotal,
})
}
}
maybeAddLine(draft.NumNights, draft.Nights, locale.PgettextNoop("Night", "cart"))
maybeAddLine(draft.NumAdults, draft.Adults, locale.PgettextNoop("Adult", "cart"))
maybeAddLine(draft.NumTeenagers, draft.Teenagers, locale.PgettextNoop("Teenager", "cart"))
maybeAddLine(draft.NumChildren, draft.Children, locale.PgettextNoop("Child", "cart"))
maybeAddLine(draft.NumDogs, draft.Dogs, locale.PgettextNoop("Dog", "cart"))
for _, option := range draft.Options {
maybeAddLine(option.Units, option.Subtotal, option.Label)
}
maybeAddLine(draft.NumAdults, draft.TouristTax, locale.PgettextNoop("Tourist tax", "cart"))
if draft.Total != "0.0" {
cart.Total = draft.Total
cart.Enabled = f.Guests.Error == nil
if downPayment != total {
cart.DownPayment = downPayment
if draft.DownPayment != draft.Total {
cart.DownPayment = draft.DownPayment
}
}

338
pkg/booking/checkin.go Normal file
View File

@ -0,0 +1,338 @@
package booking
import (
"context"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
"github.com/jackc/pgx/v4"
"math"
"net/http"
"time"
)
func serveCheckInForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
f := newCheckinForm(slug)
if err := f.FillFromDatabase(r.Context(), conn, user.Locale); err != nil {
panic(err)
}
f.MustRender(w, r, user, company)
}
func serveGuestForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
f, err := newGuestForm(r.Context(), conn, user.Locale, slug)
if err != nil {
panic(err)
}
f.MustRender(w, r, user, company)
}
func checkInBooking(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slug string) {
f := newCheckinForm(slug)
if err := f.Parse(r, user, conn); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
}
guests := make([]*database.CheckedInGuest, 0, len(f.Guests))
for _, g := range f.Guests {
guests = append(guests, g.checkedInGuest())
}
if err := conn.CheckInGuests(r.Context(), f.Slug, guests); err != nil {
panic(err)
}
httplib.Redirect(w, r, "/admin/bookings", http.StatusSeeOther)
}
type checkInForm struct {
Slug string
Guests []*guestForm
}
func newCheckinForm(slug string) *checkInForm {
return &checkInForm{
Slug: slug,
}
}
func (f *checkInForm) FillFromDatabase(ctx context.Context, conn *database.Conn, l *locale.Locale) error {
documentTypes := form.MustGetDocumentTypeOptions(ctx, conn, l)
sexes := mustGetSexOptions(ctx, conn, l)
countries := form.MustGetCountryOptions(ctx, conn, l)
rows, err := conn.Query(ctx, `
select array[id_document_type_id]
, id_document_number
, coalesce(id_document_issue_date::text, '')
, given_name
, first_surname
, second_surname
, array[sex_id]
, birthdate::text
, array[guest.country_code::text]
, coalesce(guest.phone::text, '')
, guest.address
from booking_guest as guest
join booking using (booking_id)
where slug = $1
`, f.Slug)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
guest := newGuestFormWithOptions(documentTypes, sexes, countries, "", nil)
if err := guest.FillFromRow(rows); err != nil {
return err
}
f.Guests = append(f.Guests, guest)
}
if len(f.Guests) == 0 {
var numberGuests int
var address string
var country []string
row := conn.QueryRow(ctx, "select number_adults + number_teenagers, coalesce(address, ''), array[coalesce(country_code, '')] from booking where slug = $1", f.Slug)
if err = row.Scan(&numberGuests, &address, &country); err != nil {
return err
}
guests := make([]*guestForm, 0, numberGuests)
for i := 0; i < numberGuests; i++ {
guests = append(guests, newGuestFormWithOptions(documentTypes, sexes, countries, address, country))
}
f.Guests = guests
}
return nil
}
func mustGetSexOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*form.Option {
return form.MustGetOptions(ctx, conn, "select sex.sex_id::text, coalesce(i18n.name, sex.name) as l10n_name from sex left join sex_i18n as i18n on sex.sex_id = i18n.sex_id and i18n.lang_tag = $1 order by l10n_name", l.Language)
}
func (f *checkInForm) Parse(r *http.Request, user *auth.User, conn *database.Conn) error {
if err := r.ParseForm(); err != nil {
return err
}
documentTypes := form.MustGetDocumentTypeOptions(r.Context(), conn, user.Locale)
sexes := mustGetSexOptions(r.Context(), conn, user.Locale)
countries := form.MustGetCountryOptions(r.Context(), conn, user.Locale)
guest := newGuestFormWithOptions(documentTypes, sexes, countries, "", nil)
count := guest.count(r)
f.Guests = make([]*guestForm, 0, count)
guest.FillValueIndex(r, 0)
f.Guests = append(f.Guests, guest)
for i := 1; i < count; i++ {
guest = newGuestFormWithOptions(documentTypes, sexes, countries, "", nil)
guest.FillValueIndex(r, i)
f.Guests = append(f.Guests, guest)
}
return nil
}
func (f *checkInForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
allOK := true
for _, g := range f.Guests {
if ok, err := g.Valid(ctx, conn, l); err != nil {
return false, err
} else if !ok {
allOK = false
}
}
return allOK, nil
}
func (f *checkInForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, f, "booking/checkin.gohtml", "booking/guest.gohtml")
}
type guestForm struct {
IDDocumentType *form.Select
IDDocumentNumber *form.Input
IDDocumentDate *form.Input
GivenName *form.Input
FirstSurname *form.Input
SecondSurname *form.Input
Sex *form.Select
Birthdate *form.Input
Country *form.Select
Address *form.Input
Phone *form.Input
}
func newGuestForm(ctx context.Context, conn *database.Conn, l *locale.Locale, slug string) (*guestForm, error) {
documentTypes := form.MustGetDocumentTypeOptions(ctx, conn, l)
sexes := mustGetSexOptions(ctx, conn, l)
countries := form.MustGetCountryOptions(ctx, conn, l)
var address string
var country []string
row := conn.QueryRow(ctx, "select coalesce(address, ''), array[coalesce(country_code, '')] from booking where slug = $1", slug)
if err := row.Scan(&address, &country); err != nil {
return nil, err
}
return newGuestFormWithOptions(documentTypes, sexes, countries, address, country), nil
}
func newGuestFormWithOptions(documentTypes []*form.Option, sexes []*form.Option, countries []*form.Option, address string, selectedCountry []string) *guestForm {
return &guestForm{
IDDocumentType: &form.Select{
Name: "id_document_type",
Options: documentTypes,
},
IDDocumentNumber: &form.Input{
Name: "id_document_number",
},
IDDocumentDate: &form.Input{
Name: "id_document_date",
},
GivenName: &form.Input{
Name: "given_name",
},
FirstSurname: &form.Input{
Name: "first_surname",
},
SecondSurname: &form.Input{
Name: "second_surname",
},
Sex: &form.Select{
Name: "sex",
Options: sexes,
},
Birthdate: &form.Input{
Name: "birthdate",
},
Country: &form.Select{
Name: "country",
Options: countries,
Selected: selectedCountry,
},
Address: &form.Input{
Name: "address",
Val: address,
},
Phone: &form.Input{
Name: "phone",
},
}
}
func (f *guestForm) count(r *http.Request) int {
keys := []string{f.IDDocumentType.Name, f.IDDocumentNumber.Name, f.IDDocumentDate.Name, f.GivenName.Name, f.FirstSurname.Name, f.SecondSurname.Name, f.Sex.Name, f.Birthdate.Name, f.Country.Name, f.Address.Name, f.Phone.Name}
min := math.MaxInt
for _, key := range keys {
l := len(r.Form[key])
if len(r.Form[key]) < min {
min = l
}
}
return min
}
func (f *guestForm) FillValueIndex(r *http.Request, idx int) {
f.IDDocumentType.FillValueIndex(r, idx)
f.IDDocumentNumber.FillValueIndex(r, idx)
f.IDDocumentDate.FillValueIndex(r, idx)
f.GivenName.FillValueIndex(r, idx)
f.FirstSurname.FillValueIndex(r, idx)
f.SecondSurname.FillValueIndex(r, idx)
f.Sex.FillValueIndex(r, idx)
f.Birthdate.FillValueIndex(r, idx)
f.Country.FillValueIndex(r, idx)
f.Address.FillValueIndex(r, idx)
f.Phone.FillValueIndex(r, idx)
}
func (f *guestForm) FillFromRow(row pgx.Rows) error {
return row.Scan(
&f.IDDocumentType.Selected,
&f.IDDocumentNumber.Val,
&f.IDDocumentDate.Val,
&f.GivenName.Val,
&f.FirstSurname.Val,
&f.SecondSurname.Val,
&f.Sex.Selected,
&f.Birthdate.Val,
&f.Country.Selected,
&f.Phone.Val,
&f.Address.Val,
)
}
func (f *guestForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
today := time.Now()
yesterday := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
v.CheckSelectedOptions(f.IDDocumentType, l.GettextNoop("Selected ID document type is not valid."))
v.CheckRequired(f.IDDocumentNumber, l.GettextNoop("ID document number can not be empty."))
if f.IDDocumentDate.Val != "" {
if v.CheckValidDate(f.IDDocumentDate, l.GettextNoop("ID document issue date must be a valid date.")) {
v.CheckMaxDate(f.IDDocumentDate, yesterday, l.Gettext("ID document issue date must be in the past."))
}
}
v.CheckRequired(f.GivenName, l.GettextNoop("Full name can not be empty."))
v.CheckRequired(f.FirstSurname, l.GettextNoop("Full name can not be empty."))
v.CheckSelectedOptions(f.Sex, l.GettextNoop("Selected sex is not valid."))
if v.CheckRequired(f.Birthdate, l.GettextNoop("Birthdate can not be empty")) {
if v.CheckValidDate(f.Birthdate, l.GettextNoop("Birthdate must be a valid date.")) {
v.CheckMaxDate(f.Birthdate, yesterday, l.Gettext("Birthdate must be in the past."))
}
}
var country string
if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) {
country = f.Country.Selected[0]
}
if f.Phone.Val != "" && country != "" {
if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
return v.AllOK, nil
}
func (f *guestForm) checkedInGuest() *database.CheckedInGuest {
birthdate, err := time.Parse(database.ISODateFormat, f.Birthdate.Val)
if err != nil {
panic(err)
}
issueDate, err := time.Parse(database.ISODateFormat, f.IDDocumentDate.Val)
if err != nil {
issueDate = time.Time{}
}
return &database.CheckedInGuest{
IDDocumentType: f.IDDocumentType.String(),
IDDocumentNumber: f.IDDocumentNumber.Val,
IDDocumentIssueDate: issueDate,
GivenName: f.GivenName.Val,
FirstSurname: f.FirstSurname.Val,
SecondSurname: f.SecondSurname.Val,
Sex: f.Sex.String(),
Birthdate: birthdate,
CountryCode: f.Country.String(),
Phone: f.Phone.Val,
Address: f.Address.Val,
}
}
func (f *guestForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminNoLayout(w, r, user, company, "booking/guest.gohtml", f)
}

130
pkg/booking/filter.go Normal file
View File

@ -0,0 +1,130 @@
package booking
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
"dev.tandem.ws/tandem/camper/pkg/locale"
)
type filterForm struct {
company *auth.Company
HolderName *form.Input
BookingStatus *form.Select
FromDate *form.Input
ToDate *form.Input
Cursor *form.Cursor
}
func newFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *filterForm {
return &filterForm{
company: company,
HolderName: &form.Input{
Name: "holder_name",
},
BookingStatus: &form.Select{
Name: "booking_status",
Options: mustGetBookingStatusOptions(ctx, conn, locale),
},
FromDate: &form.Input{
Name: "from_date",
},
ToDate: &form.Input{
Name: "to_date",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 25,
},
}
}
func mustGetBookingStatusOptions(ctx context.Context, conn *database.Conn, locale *locale.Locale) []*form.Option {
return form.MustGetOptions(ctx, conn, `
select booking_status.booking_status
, isi18n.name
from booking_status
join booking_status_i18n isi18n using(booking_status)
where isi18n.lang_tag = $1
and booking_status <> 'created'
order by booking_status`, locale.Language)
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.HolderName.FillValue(r)
f.BookingStatus.FillValue(r)
f.FromDate.FillValue(r)
f.ToDate.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("booking.company_id = $%d", f.company.ID)
maybeAppendWhere("booking.holder_name ILIKE $%d", f.HolderName.Val, func(v string) interface{} {
return "%" + v + "%"
})
if len(f.BookingStatus.Selected) == 0 {
where = append(where, "booking.booking_status <> 'created'")
} else {
maybeAppendWhere("booking.booking_status = $%d", f.BookingStatus.String(), nil)
}
maybeAppendWhere("lower(stay) >= $%d", f.FromDate.Val, nil)
maybeAppendWhere("lower(stay) <= $%d", f.ToDate.Val, nil)
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(lower(stay), booking_id) < ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(bookings []*bookingEntry) []*bookingEntry {
return form.BuildCursor(f.Cursor, bookings, func(entry *bookingEntry) []string {
return []string{entry.ArrivalDate.Format(database.ISODateFormat), strconv.Itoa(entry.ID)}
})
}
func (f *filterForm) HasValue() bool {
return f.HolderName.Val != "" ||
(len(f.BookingStatus.Selected) > 0 && f.BookingStatus.Selected[0] != "") ||
f.FromDate.Val != "" ||
f.ToDate.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

View File

@ -43,7 +43,6 @@ func requestPayment(w http.ResponseWriter, r *http.Request, user *auth.User, com
f.Customer.Email.Val,
f.Customer.Phone.Val,
user.Locale.Language,
f.Customer.ACSICard.Checked,
)
if err != nil {
panic(err)

67
pkg/booking/prebooking.go Normal file
View File

@ -0,0 +1,67 @@
package booking
import (
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type PrebookingHandler struct {
}
func NewPrebookingHandler() *PrebookingHandler {
return &PrebookingHandler{}
}
func (h *PrebookingHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
servePrebookingIndex(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
http.NotFound(w, r)
}
})
}
func servePrebookingIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
filters := newFilterForm(r.Context(), conn, company, user.Locale)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
filters.BookingStatus.Selected = []string{"created"}
bookings, err := collectBookingEntries(r.Context(), conn, user.Locale.Language, filters)
if err != nil {
panic(err)
}
page := &prebookingIndex{
Bookings: filters.buildCursor(bookings),
Filters: filters,
}
page.MustRender(w, r, user, company)
}
type prebookingIndex struct {
Bookings []*bookingEntry
Filters *filterForm
}
func (page prebookingIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "prebooking/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "prebooking/index.gohtml", "prebooking/results.gohtml")
}
}

View File

@ -119,6 +119,7 @@ type bookingGuestFields struct {
NumberTeenagers *form.Input
NumberChildren *form.Input
NumberDogs *form.Input
ACSICard *form.Checkbox
Error error
}
@ -129,11 +130,12 @@ type bookingOptionFields struct {
}
type campsiteTypeOption struct {
ID int
Label string
Min int
Max int
Input *form.Input
ID int
Label string
Min int
Max int
Input *form.Input
Subtotal string
}
type bookingCustomerFields struct {
@ -144,24 +146,27 @@ type bookingCustomerFields struct {
Country *form.Select
Email *form.Input
Phone *form.Input
ACSICard *form.Checkbox
Agreement *form.Checkbox
}
func newEmptyBookingForm(ctx context.Context, conn *database.Conn, company *auth.Company, l *locale.Locale) *bookingForm {
return &bookingForm{
CampsiteType: &form.Select{
Name: "campsite_type",
Options: form.MustGetOptions(ctx, conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID),
},
PaymentSlug: &form.Input{
Name: "payment_slug",
},
}
}
func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn, l *locale.Locale) (*bookingForm, error) {
if err := r.ParseForm(); err != nil {
return nil, err
}
f := &bookingForm{
CampsiteType: &form.Select{
Name: "campsite_type",
Options: form.MustGetOptions(r.Context(), conn, "select type.slug, coalesce(i18n.name, type.name) as l10n_name from campsite_type as type left join campsite_type_i18n as i18n on type.campsite_type_id = i18n.campsite_type_id and i18n.lang_tag = $1 where company_id = $2 and active order by position, l10n_name", l.Language, company.ID),
},
PaymentSlug: &form.Input{
Name: "payment_slug",
},
}
f := newEmptyBookingForm(r.Context(), conn, company, l)
f.CampsiteType.FillValue(r)
f.PaymentSlug.FillValue(r)
campsiteType := f.CampsiteType.String()
@ -183,7 +188,7 @@ func newBookingForm(r *http.Request, company *auth.Company, conn *database.Conn,
return f, nil
}
f.Guests, err = newBookingGuestFields(r.Context(), conn, campsiteType)
f.Guests, err = newBookingGuestFields(r.Context(), conn, campsiteType, f.Dates.ArrivalDate.Val, f.Dates.DepartureDate.Val)
if err != nil {
return nil, err
}
@ -240,14 +245,14 @@ func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string
row := conn.QueryRow(ctx, `
select lower(bookable_nights),
upper(bookable_nights) - 1,
greatest(min(lower(season_range)), current_timestamp::date),
max(upper(season_range))
greatest(min(lower(season_range)), lower(operating_dates), current_timestamp::date),
least(max(upper(season_range)), upper(operating_dates))
from campsite_type
join campsite_type_cost using (campsite_type_id)
join season_calendar using (season_id)
where campsite_type.slug = $1
and season_range >> daterange(date_trunc('year', current_timestamp)::date, date_trunc('year', current_timestamp)::date + 1)
group by bookable_nights;
group by bookable_nights, operating_dates
`, campsiteType)
f := &DateFields{
ArrivalDate: &bookingDateInput{
@ -269,7 +274,10 @@ func NewDateFields(ctx context.Context, conn *database.Conn, campsiteType string
func (f *DateFields) FillValues(r *http.Request, l *locale.Locale) {
f.ArrivalDate.FillValue(r)
f.DepartureDate.FillValue(r)
f.AdjustValues(l)
}
func (f *DateFields) AdjustValues(l *locale.Locale) {
if f.ArrivalDate.Val != "" {
arrivalDate, err := time.Parse(database.ISODateFormat, f.ArrivalDate.Val)
if err != nil {
@ -317,7 +325,7 @@ func (f *DateFields) Valid(v *form.Validator, l *locale.Locale) {
}
}
func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteType string) (*bookingGuestFields, error) {
func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteType string, arrivalDate string, departureDate string) (*bookingGuestFields, error) {
f := &bookingGuestFields{
NumberAdults: &form.Input{Name: "number_adults"},
NumberTeenagers: &form.Input{Name: "number_teenagers"},
@ -327,17 +335,26 @@ func newBookingGuestFields(ctx context.Context, conn *database.Conn, campsiteTyp
select max_campers
, overflow_allowed
, pet.cost_per_night is not null as dogs_allowed
, exists (
select 1 from acsi_calendar
where acsi_calendar.campsite_type_id = campsite_type.campsite_type_id
and acsi_range && daterange($2::date, $3::date)
) as acsi_allowed
from campsite_type
left join campsite_type_pet_cost as pet using (campsite_type_id)
where slug = $1
`, campsiteType)
`, campsiteType, arrivalDate, departureDate)
var dogsAllowed bool
if err := row.Scan(&f.MaxGuests, &f.OverflowAllowed, &dogsAllowed); err != nil {
var ACSIAllowed bool
if err := row.Scan(&f.MaxGuests, &f.OverflowAllowed, &dogsAllowed, &ACSIAllowed); err != nil {
return nil, err
}
if dogsAllowed {
f.NumberDogs = &form.Input{Name: "number_dogs"}
}
if ACSIAllowed {
f.ACSICard = &form.Checkbox{Name: "acsi_card"}
}
return f, nil
}
@ -349,6 +366,13 @@ func (f *bookingGuestFields) FillValues(r *http.Request, l *locale.Locale) {
if f.NumberDogs != nil {
fillNumericField(f.NumberDogs, r, 0)
}
if f.ACSICard != nil {
f.ACSICard.FillValue(r)
}
f.AdjustValues(numGuests, l)
}
func (f *bookingGuestFields) AdjustValues(numGuests int, l *locale.Locale) {
if numGuests > f.MaxGuests {
if f.OverflowAllowed {
f.Overflow = true
@ -459,6 +483,42 @@ func (f *bookingOptionFields) FillValues(r *http.Request) {
}
}
func (f *bookingOptionFields) FillFromDatabase(ctx context.Context, conn *database.Conn, bookingID int) error {
rows, err := conn.Query(ctx, `
select campsite_type_option.campsite_type_option_id
, coalesce(units, lower(range))::text
, to_price(coalesce(subtotal, 0), decimal_digits)
from booking
join campsite_type_option using (campsite_type_id)
left join booking_option
on booking.booking_id = booking_option.booking_id
and booking_option.campsite_type_option_id = campsite_type_option.campsite_type_option_id
join currency using (currency_code)
where booking.booking_id = $1
`, bookingID)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int
var units string
var subtotal string
if err = rows.Scan(&id, &units, &subtotal); err != nil {
return err
}
for _, option := range f.Options {
if option.ID == id {
option.Input.Val = units
option.Subtotal = subtotal
break
}
}
}
return nil
}
func (f *bookingOptionFields) Valid(v *form.Validator, l *locale.Locale) {
for _, option := range f.Options {
if v.CheckRequired(option.Input, fmt.Sprintf(l.Gettext("%s can not be empty"), option.Label)) {
@ -487,7 +547,7 @@ func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *local
},
Country: &form.Select{
Name: "country",
Options: form.MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language),
Options: form.MustGetCountryOptions(ctx, conn, l),
},
Email: &form.Input{
Name: "email",
@ -495,9 +555,6 @@ func newBookingCustomerFields(ctx context.Context, conn *database.Conn, l *local
Phone: &form.Input{
Name: "phone",
},
ACSICard: &form.Checkbox{
Name: "acsi_card",
},
Agreement: &form.Checkbox{
Name: "agreement",
},
@ -512,7 +569,6 @@ func (f *bookingCustomerFields) FillValues(r *http.Request) {
f.Country.FillValue(r)
f.Email.FillValue(r)
f.Phone.FillValue(r)
f.ACSICard.FillValue(r)
f.Agreement.FillValue(r)
}

View File

@ -1,3 +1,3 @@
package build
const Version = "1.5~git"
const Version = "1.8~git"

View File

@ -8,10 +8,12 @@ package campsite
import (
"context"
"net/http"
"time"
"github.com/jackc/pgx/v4"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/booking"
"dev.tandem.ws/tandem/camper/pkg/campsite/types"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
@ -88,54 +90,57 @@ func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *dat
}
func serveCampsiteIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
campsites, err := collectCampsiteEntries(r.Context(), company, conn)
page := newCampsiteIndex()
if err := page.Parse(r); err != nil {
panic(err)
}
var err error
from := page.From.Date()
to := page.To.Date().AddDate(0, 1, 0)
page.Campsites, err = booking.CollectCampsiteEntries(r.Context(), company, conn, from, to, "")
if err != nil {
panic(err)
}
page := &campsiteIndex{
Campsites: campsites,
}
page.Months = booking.CollectMonths(from, to)
page.MustRender(w, r, user, company)
}
func collectCampsiteEntries(ctx context.Context, company *auth.Company, conn *database.Conn) ([]*campsiteEntry, error) {
rows, err := conn.Query(ctx, `
select campsite.label
, campsite_type.name
, campsite.active
from campsite
join campsite_type using (campsite_type_id)
where campsite.company_id = $1
order by label`, company.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var campsites []*campsiteEntry
for rows.Next() {
entry := &campsiteEntry{}
if err = rows.Scan(&entry.Label, &entry.Type, &entry.Active); err != nil {
return nil, err
}
campsites = append(campsites, entry)
}
return campsites, nil
}
type campsiteEntry struct {
Label string
Type string
Active bool
}
type campsiteIndex struct {
Campsites []*campsiteEntry
From *form.Month
To *form.Month
Campsites []*booking.CampsiteEntry
Months []*booking.Month
}
func newCampsiteIndex() *campsiteIndex {
now := time.Now()
from := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
to := from.AddDate(0, 3, 0)
return &campsiteIndex{
From: &form.Month{
Name: "from",
Year: from.Year(),
Month: from.Month(),
},
To: &form.Month{
Name: "to",
Year: to.Year(),
Month: to.Month(),
},
}
}
func (page *campsiteIndex) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
page.From.FillValue(r)
page.To.FillValue(r)
return nil
}
func (page *campsiteIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "web/templates/campground_map.svg")
template.MustRenderAdminFiles(w, r, user, company, page, "campsite/index.gohtml", "booking/grid.gohtml")
}
func addCampsite(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {

403
pkg/customer/admin.go Normal file
View File

@ -0,0 +1,403 @@
package customer
import (
"context"
"fmt"
"net/http"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
httplib "dev.tandem.ws/tandem/camper/pkg/http"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/template"
)
type AdminHandler struct {
}
func NewAdminHandler() *AdminHandler {
return &AdminHandler{}
}
func (h *AdminHandler) Handler(user *auth.User, company *auth.Company, conn *database.Conn) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
serveCustomerIndex(w, r, user, company, conn)
case http.MethodPost:
addCustomer(w, r, user, company, conn)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPost)
}
case "new":
switch r.Method {
case http.MethodGet:
f := NewContactForm(r.Context(), conn, user.Locale)
f.MustRender(w, r, user, company)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet)
}
default:
f := NewContactForm(r.Context(), conn, user.Locale)
if err := f.FillFromDatabase(r.Context(), conn, head); err != nil {
if database.ErrorIsNotFound(err) {
http.NotFound(w, r)
return
}
panic(err)
}
h.customerHandler(user, company, conn, f).ServeHTTP(w, r)
}
})
}
func (h *AdminHandler) customerHandler(user *auth.User, company *auth.Company, conn *database.Conn, f *ContactForm) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var head string
head, r.URL.Path = httplib.ShiftPath(r.URL.Path)
switch head {
case "":
switch r.Method {
case http.MethodGet:
f.MustRender(w, r, user, company)
case http.MethodPut:
editCustomer(w, r, user, company, conn, f)
default:
httplib.MethodNotAllowed(w, r, http.MethodGet, http.MethodPut)
}
default:
http.NotFound(w, r)
}
})
}
func serveCustomerIndex(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
filters := newFilterForm(company)
if err := filters.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
customers, err := collectCustomerEntries(r.Context(), conn, company, filters)
if err != nil {
panic(err)
}
page := &customerIndex{
Customers: filters.buildCursor(customers),
Filters: filters,
}
page.MustRender(w, r, user, company)
}
func collectCustomerEntries(ctx context.Context, conn *database.Conn, company *auth.Company, filters *filterForm) ([]*customerEntry, error) {
where, args := filters.BuildQuery(nil)
rows, err := conn.Query(ctx, fmt.Sprintf(`
select contact_id
, '/admin/customers/' || slug
, name
, coalesce(email::text, '')
, coalesce(phone::text, '')
from contact
left join contact_email using (contact_id)
left join contact_phone using (contact_id)
where (%s)
order by name, contact_id
LIMIT %d
`, where, filters.PerPage()+1), args...)
if err != nil {
return nil, err
}
defer rows.Close()
var customers []*customerEntry
for rows.Next() {
customer := &customerEntry{}
if err = rows.Scan(&customer.ID, &customer.URL, &customer.Name, &customer.Email, &customer.Phone); err != nil {
return nil, err
}
customers = append(customers, customer)
}
return customers, nil
}
type customerEntry struct {
ID int
URL string
Name string
Email string
Phone string
}
type customerIndex struct {
Customers []*customerEntry
Filters *filterForm
}
func (page *customerIndex) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
if httplib.IsHTMxRequest(r) && page.Filters.Paginated() {
template.MustRenderAdminNoLayout(w, r, user, company, "customer/results.gohtml", page)
} else {
template.MustRenderAdminFiles(w, r, user, company, page, "customer/index.gohtml", "customer/results.gohtml")
}
}
func addCustomer(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn) {
f := NewContactForm(r.Context(), conn, user.Locale)
processCustomerForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
var err error
f.Slug, err = tx.AddContact(ctx, company.ID, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
return err
})
}
func editCustomer(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *ContactForm) {
processCustomerForm(w, r, user, company, conn, f, func(ctx context.Context, tx *database.Tx) error {
_, err := tx.EditContact(ctx, f.Slug, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
return err
})
}
func processCustomerForm(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, f *ContactForm, act func(ctx context.Context, tx *database.Tx) error) {
if err := f.Parse(r); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := user.VerifyCSRFToken(r); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
if ok, err := f.Valid(r.Context(), conn, user.Locale); err != nil {
panic(err)
} else if !ok {
if !httplib.IsHTMxRequest(r) {
w.WriteHeader(http.StatusUnprocessableEntity)
}
f.MustRender(w, r, user, company)
return
}
tx := conn.MustBegin(r.Context())
if err := act(r.Context(), tx); err == nil {
if err := tx.Commit(r.Context()); err != nil {
panic(err)
}
} else {
if err := tx.Rollback(r.Context()); err != nil {
panic(err)
}
panic(err)
}
httplib.Redirect(w, r, "/admin/customers", http.StatusSeeOther)
}
type ContactForm struct {
URL string
Slug string
FullName *form.Input
IDDocumentType *form.Select
IDDocumentNumber *form.Input
Address *form.Input
City *form.Input
Province *form.Input
PostalCode *form.Input
Country *form.Select
Email *form.Input
Phone *form.Input
}
func NewContactForm(ctx context.Context, conn *database.Conn, l *locale.Locale) *ContactForm {
return &ContactForm{
FullName: &form.Input{
Name: "full_name",
},
IDDocumentType: &form.Select{
Name: "id_document_type",
Options: form.MustGetDocumentTypeOptions(ctx, conn, l),
},
IDDocumentNumber: &form.Input{
Name: "id_document_number",
},
Address: &form.Input{
Name: "address",
},
City: &form.Input{
Name: "city",
},
Province: &form.Input{
Name: "province",
},
PostalCode: &form.Input{
Name: "postal_code",
},
Country: &form.Select{
Name: "country",
Options: form.MustGetCountryOptions(ctx, conn, l),
},
Email: &form.Input{
Name: "email",
},
Phone: &form.Input{
Name: "phone",
},
}
}
func (f *ContactForm) FillFromDatabase(ctx context.Context, conn *database.Conn, slug string) error {
row := conn.QueryRow(ctx, `
select '/admin/customers/' || slug
, slug
, name
, array[id_document_type_id::text]
, id_document_number
, address
, city
, province
, postal_code
, array[country_code::text]
, coalesce(email::text, '')
, coalesce(phone::text, '')
from contact as text
left join contact_email using (contact_id)
left join contact_phone using (contact_id)
where slug = $1
`, slug)
return row.Scan(
&f.URL,
&f.Slug,
&f.FullName.Val,
&f.IDDocumentType.Selected,
&f.IDDocumentNumber.Val,
&f.Address.Val,
&f.City.Val,
&f.Province.Val,
&f.PostalCode.Val,
&f.Country.Selected,
&f.Email.Val,
&f.Phone.Val,
)
}
func (f *ContactForm) FillFromBooking(ctx context.Context, conn *database.Conn, bookingID int) error {
row := conn.QueryRow(ctx, `
select ''
, ''
, holder_name
, array[]::text[]
, ''
, coalesce(address, '')
, coalesce(city, '')
, ''
, coalesce(postal_code, '')
, array[coalesce(country_code::text, '')]
, coalesce(email::text, '')
, coalesce(phone::text, '')
from booking
where booking_id = $1
`, bookingID)
return row.Scan(
&f.URL,
&f.Slug,
&f.FullName.Val,
&f.IDDocumentType.Selected,
&f.IDDocumentNumber.Val,
&f.Address.Val,
&f.City.Val,
&f.Province.Val,
&f.PostalCode.Val,
&f.Country.Selected,
&f.Email.Val,
&f.Phone.Val,
)
}
func (f *ContactForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.FullName.FillValue(r)
f.IDDocumentType.FillValue(r)
f.IDDocumentNumber.FillValue(r)
f.Address.FillValue(r)
f.City.FillValue(r)
f.Province.FillValue(r)
f.PostalCode.FillValue(r)
f.Country.FillValue(r)
f.Email.FillValue(r)
f.Phone.FillValue(r)
return nil
}
func (f *ContactForm) Valid(ctx context.Context, conn *database.Conn, l *locale.Locale) (bool, error) {
v := form.NewValidator(l)
var country string
if v.CheckSelectedOptions(f.Country, l.GettextNoop("Selected country is not valid.")) {
country = f.Country.Selected[0]
}
v.CheckSelectedOptions(f.IDDocumentType, l.GettextNoop("Selected ID document type is not valid."))
v.CheckRequired(f.IDDocumentNumber, l.GettextNoop("ID document number can not be empty."))
if v.CheckRequired(f.FullName, l.GettextNoop("Full name can not be empty.")) {
v.CheckMinLength(f.FullName, 1, l.GettextNoop("Full name must have at least one letter."))
}
v.CheckRequired(f.Address, l.GettextNoop("Address can not be empty."))
v.CheckRequired(f.City, l.GettextNoop("Town or village can not be empty."))
if v.CheckRequired(f.PostalCode, l.GettextNoop("Postcode can not be empty.")) && country != "" {
if _, err := v.CheckValidPostalCode(ctx, conn, f.PostalCode, country, l.GettextNoop("This postcode is not valid.")); err != nil {
return false, err
}
}
if f.Email.Val != "" {
v.CheckValidEmail(f.Email, l.GettextNoop("This email is not valid. It should be like name@domain.com."))
}
if f.Phone.Val != "" && country != "" {
if _, err := v.CheckValidPhone(ctx, conn, f.Phone, country, l.GettextNoop("This phone number is not valid.")); err != nil {
return false, err
}
}
return v.AllOK, nil
}
func (f *ContactForm) UpdateOrCreate(ctx context.Context, company *auth.Company, tx *database.Tx) (int, error) {
var contactID int
row := tx.QueryRow(ctx, `
select contact_id, slug from contact where id_document_type_id = $1 and id_document_number = $2 and country_code = $3
`,
f.IDDocumentType.String(),
f.IDDocumentNumber.Val,
f.Country.String(),
)
if err := row.Scan(&contactID, &f.Slug); err != nil {
if database.ErrorIsNotFound(err) {
f.Slug, err = tx.AddContact(ctx, company.ID, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
if err != nil {
return 0, err
}
contactID, err = tx.GetInt(ctx, "select contact_id from contact where slug = $1", f.Slug)
if err != nil {
return 0, err
}
} else {
return 0, err
}
}
_, err := tx.EditContact(ctx, f.Slug, f.FullName.Val, f.IDDocumentType.String(), f.IDDocumentNumber.Val, f.Phone.Val, f.Email.Val, f.Address.Val, f.City.Val, f.Province.Val, f.PostalCode.Val, f.Country.String())
if err != nil {
return 0, err
}
return contactID, nil
}
func (f *ContactForm) MustRender(w http.ResponseWriter, r *http.Request, user *auth.User, company *auth.Company) {
template.MustRenderAdminFiles(w, r, user, company, f, "customer/form.gohtml", "customer/contact.gohtml")
}

99
pkg/customer/filter.go Normal file
View File

@ -0,0 +1,99 @@
package customer
import (
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/form"
)
type filterForm struct {
company *auth.Company
Name *form.Input
Email *form.Input
Cursor *form.Cursor
}
func newFilterForm(company *auth.Company) *filterForm {
return &filterForm{
company: company,
Name: &form.Input{
Name: "name",
},
Email: &form.Input{
Name: "email",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 25,
},
}
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Name.FillValue(r)
f.Email.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("company_id = $%d", f.company.ID)
maybeAppendWhere("name ILIKE $%d", f.Name.Val, func(v string) interface{} {
return "%" + v + "%"
})
maybeAppendWhere("email ILIKE $%d", f.Email.Val, func(v string) interface{} {
return "%" + v + "%"
})
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(name, contact_id) > ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(customers []*customerEntry) []*customerEntry {
return form.BuildCursor(f.Cursor, customers, func(entry *customerEntry) []string {
return []string{entry.Name, strconv.Itoa(entry.ID)}
})
}
func (f *filterForm) HasValue() bool {
return f.Name.Val != "" ||
f.Email.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

View File

@ -0,0 +1,84 @@
package database
import (
"fmt"
"time"
"github.com/jackc/pgio"
"github.com/jackc/pgtype"
)
type CheckedInGuest struct {
IDDocumentType string
IDDocumentNumber string
IDDocumentIssueDate time.Time
GivenName string
FirstSurname string
SecondSurname string
Sex string
Birthdate time.Time
CountryCode string
Phone string
Address string
}
func (src CheckedInGuest) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := CheckedInGuestTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
var idDocumentIssueDate interface{}
var noDate time.Time
if src.IDDocumentIssueDate != noDate {
idDocumentIssueDate = src.IDDocumentIssueDate
}
values := []interface{}{
src.IDDocumentType,
src.IDDocumentNumber,
idDocumentIssueDate,
src.GivenName,
src.FirstSurname,
src.SecondSurname,
src.Sex,
src.Birthdate,
src.CountryCode,
src.Phone,
src.Address,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}
type CheckedInGuestArray []*CheckedInGuest
func (src CheckedInGuestArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
typeName := CheckedInGuestTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
arrayHeader := pgtype.ArrayHeader{
ElementOID: int32(dt.OID),
Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}},
}
buf = arrayHeader.EncodeBinary(ci, buf)
for _, optionUnits := range src {
sp := len(buf)
buf = pgio.AppendInt32(buf, -1)
elemBuf, err := optionUnits.EncodeBinary(ci, buf)
if err != nil {
return nil, err
}
if elemBuf != nil {
buf = elemBuf
pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4))
}
}
return buf, nil
}

View File

@ -0,0 +1,76 @@
package database
import (
"fmt"
"github.com/jackc/pgio"
"github.com/jackc/pgtype"
)
const EditedInvoiceProductTypeName = "edited_invoice_product"
type EditedInvoiceProduct struct {
*NewInvoiceProduct
InvoiceProductId int
}
func (src EditedInvoiceProduct) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := EditedInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
var invoiceProductId interface{}
if src.InvoiceProductId > 0 {
invoiceProductId = src.InvoiceProductId
}
var productId interface{}
if src.ProductId > 0 {
productId = src.ProductId
}
values := []interface{}{
invoiceProductId,
productId,
src.Name,
src.Description,
src.Price,
src.Quantity,
src.Discount,
src.Taxes,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}
type EditedInvoiceProductArray []*EditedInvoiceProduct
func (src EditedInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
typeName := EditedInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
arrayHeader := pgtype.ArrayHeader{
ElementOID: int32(dt.OID),
Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}},
}
buf = arrayHeader.EncodeBinary(ci, buf)
for _, product := range src {
sp := len(buf)
buf = pgio.AppendInt32(buf, -1)
elemBuf, err := product.EncodeBinary(ci, buf)
if err != nil {
return nil, err
}
if elemBuf != nil {
buf = elemBuf
pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4))
}
}
return buf, nil
}

View File

@ -0,0 +1,76 @@
package database
import (
"fmt"
"github.com/jackc/pgio"
"github.com/jackc/pgtype"
)
const NewInvoiceProductTypeName = "new_invoice_product"
type NewInvoiceProduct struct {
ProductId int
Name string
Description string
Price string
Quantity int
Discount float64
Taxes []int
}
func (src NewInvoiceProduct) EncodeBinary(ci *pgtype.ConnInfo, dst []byte) ([]byte, error) {
typeName := NewInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
var productId interface{}
if src.ProductId > 0 {
productId = src.ProductId
}
values := []interface{}{
productId,
src.Name,
src.Description,
src.Price,
src.Quantity,
src.Discount,
src.Taxes,
}
ct := pgtype.NewValue(dt.Value).(*pgtype.CompositeType)
if err := ct.Set(values); err != nil {
return nil, err
}
return ct.EncodeBinary(ci, dst)
}
type NewInvoiceProductArray []*NewInvoiceProduct
func (src NewInvoiceProductArray) EncodeBinary(ci *pgtype.ConnInfo, buf []byte) ([]byte, error) {
typeName := NewInvoiceProductTypeName
dt, ok := ci.DataTypeForName(typeName)
if !ok {
return nil, fmt.Errorf("unable to find oid for type name %v", typeName)
}
arrayHeader := pgtype.ArrayHeader{
ElementOID: int32(dt.OID),
Dimensions: []pgtype.ArrayDimension{{Length: int32(len(src)), LowerBound: 1}},
}
buf = arrayHeader.EncodeBinary(ci, buf)
for _, product := range src {
sp := len(buf)
buf = pgio.AppendInt32(buf, -1)
elemBuf, err := product.EncodeBinary(ci, buf)
if err != nil {
return nil, err
}
if elemBuf != nil {
buf = elemBuf
pgio.SetInt32(buf[sp:], int32(len(buf[sp:])-4))
}
}
return buf, nil
}

View File

@ -7,6 +7,7 @@ package database
import (
"context"
"github.com/jackc/pgtype/zeronull"
"golang.org/x/text/language"
)
@ -348,6 +349,45 @@ func (tx *Tx) TranslateHome(ctx context.Context, companyID int, langTag language
return err
}
func (c *Conn) ReadyPayment(ctx context.Context, paymentSlug string, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag, acsiCard bool) (int, error) {
return c.GetInt(ctx, "select ready_payment($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", paymentSlug, customerName, customerAddress, customerPostCode, customerCity, customerCountryCode, customerEmail, customerPhone, customerLangTag, acsiCard)
func (c *Conn) ReadyPayment(ctx context.Context, paymentSlug string, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag) (int, error) {
return c.GetInt(ctx, "select ready_payment($1, $2, $3, $4, $5, $6, $7, $8, $9)", paymentSlug, customerName, customerAddress, customerPostCode, customerCity, customerCountryCode, customerEmail, customerPhone, customerLangTag)
}
func (tx *Tx) AddBookingFromPayment(ctx context.Context, paymentSlug string) (int, error) {
return tx.GetInt(ctx, "select add_booking_from_payment($1)", paymentSlug)
}
func (tx *Tx) EditBookingFromPayment(ctx context.Context, bookingSlug string, paymentSlug string) (int, error) {
return tx.GetInt(ctx, "select edit_booking_from_payment($1, $2)", bookingSlug, paymentSlug)
}
func (tx *Tx) EditBooking(ctx context.Context, bookingID int, customerName string, customerAddress string, customerPostCode string, customerCity string, customerCountryCode string, customerEmail string, customerPhone string, customerLangTag language.Tag, bookingStatus string, campsiteIDs []int) error {
_, err := tx.Exec(ctx, "select edit_booking($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", bookingID, customerName, zeronull.Text(customerAddress), zeronull.Text(customerPostCode), zeronull.Text(customerCity), zeronull.Text(customerCountryCode), zeronull.Text(customerEmail), zeronull.Text(customerPhone), customerLangTag, bookingStatus, campsiteIDs)
return err
}
func (c *Conn) CancelBooking(ctx context.Context, bookingID int) error {
_, err := c.Exec(ctx, "select cancel_booking($1)", bookingID)
return err
}
func (c *Conn) CheckInGuests(ctx context.Context, bookingSlug string, guests []*CheckedInGuest) error {
_, err := c.Exec(ctx, "select check_in_guests(booking_id, $2) from booking where slug = $1", bookingSlug, CheckedInGuestArray(guests))
return err
}
func (tx *Tx) AddInvoice(ctx context.Context, companyID int, date string, customerID int, notes string, paymentMethodID int, products NewInvoiceProductArray) (string, error) {
return tx.GetText(ctx, "select add_invoice($1, $2, $3, $4, $5, $6)", companyID, date, customerID, notes, paymentMethodID, products)
}
func (tx *Tx) EditInvoice(ctx context.Context, invoiceSlug string, invoiceStatus string, contactID int, notes string, paymentMethodID int, products EditedInvoiceProductArray) (string, error) {
return tx.GetText(ctx, "select edit_invoice($1, $2, $3, $4, $5, $6)", invoiceSlug, invoiceStatus, contactID, notes, paymentMethodID, products)
}
func (tx *Tx) AddContact(ctx context.Context, companyID int, name string, idDocumentType string, idDocumentNumber string, phone string, email string, address string, city string, province string, postalCode string, countryCode string) (string, error) {
return tx.GetText(ctx, "select add_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", companyID, name, idDocumentType, idDocumentNumber, phone, email, address, city, province, postalCode, countryCode)
}
func (tx *Tx) EditContact(ctx context.Context, contactSlug, name string, idDocumentType string, idDocumentNumber string, phone string, email string, address string, city string, province string, postalCode string, countryCode string) (string, error) {
return tx.GetText(ctx, "select edit_contact($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", contactSlug, name, idDocumentType, idDocumentNumber, phone, email, address, city, province, postalCode, countryCode)
}

View File

@ -13,6 +13,7 @@ import (
)
const (
CheckedInGuestTypeName = "checked_in_guest"
OptionUnitsTypeName = "option_units"
RedsysRequestTypeName = "redsys_request"
RedsysResponseTypeName = "redsys_response"
@ -48,6 +49,11 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error {
return err
}
discountRateOID, err := registerType(ctx, conn, &pgtype.Numeric{}, "discount_rate")
if err != nil {
return err
}
redsysRequestType, err := pgtype.NewCompositeType(
RedsysRequestTypeName,
[]pgtype.CompositeTypeField{
@ -125,6 +131,71 @@ func registerConnectionTypes(ctx context.Context, conn *pgx.Conn) error {
return err
}
checkedInGuestType, err := pgtype.NewCompositeType(
CheckedInGuestTypeName,
[]pgtype.CompositeTypeField{
{"id_document_type_id", pgtype.VarcharOID},
{"id_document_number", pgtype.TextOID},
{"id_document_issue_date", pgtype.DateOID},
{"given_name", pgtype.TextOID},
{"first_surname", pgtype.TextOID},
{"second_surname", pgtype.TextOID},
{"sex_id", pgtype.VarcharOID},
{"birthdate", pgtype.DateOID},
{"country_code", pgtype.TextOID},
{"phone", pgtype.TextOID},
{"address", pgtype.TextOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, checkedInGuestType, checkedInGuestType.TypeName()); err != nil {
return err
}
newInvoiceProductType, err := pgtype.NewCompositeType(
NewInvoiceProductTypeName,
[]pgtype.CompositeTypeField{
{"product_id", pgtype.Int4OID},
{"name", pgtype.TextOID},
{"description", pgtype.TextOID},
{"price", pgtype.TextOID},
{"quantity", pgtype.Int4OID},
{"discount_rate", discountRateOID},
{"tax", pgtype.Int4ArrayOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, newInvoiceProductType, newInvoiceProductType.TypeName()); err != nil {
return err
}
editedInvoiceProductType, err := pgtype.NewCompositeType(
EditedInvoiceProductTypeName,
[]pgtype.CompositeTypeField{
{"invoice_product_id", pgtype.Int4OID},
{"product_id", pgtype.Int4OID},
{"name", pgtype.TextOID},
{"description", pgtype.TextOID},
{"price", pgtype.TextOID},
{"quantity", pgtype.Int4OID},
{"discount_rate", discountRateOID},
{"tax", pgtype.Int4ArrayOID},
},
conn.ConnInfo(),
)
if err != nil {
return err
}
if _, err = registerType(ctx, conn, editedInvoiceProductType, editedInvoiceProductType.TypeName()); err != nil {
return err
}
return nil
}

33
pkg/form/cursor.go Normal file
View File

@ -0,0 +1,33 @@
package form
import (
"net/http"
"strings"
)
type Cursor struct {
PerPage int
Pagination bool
Name string
Val string
Colspan int
}
func (cursor *Cursor) FillValue(r *http.Request) {
cursor.Val = strings.TrimSpace(r.FormValue(cursor.Name))
cursor.Pagination = cursor.Val != ""
}
func (cursor *Cursor) Params() []string {
return strings.Split(cursor.Val, ";")
}
func BuildCursor[K interface{}](cursor *Cursor, elems []K, build func(K) []string) []K {
if len(elems) <= cursor.PerPage {
cursor.Val = ""
return elems
}
elems = elems[:cursor.PerPage]
cursor.Val = strings.Join(build(elems[cursor.PerPage-1]), ";")
return elems
}

View File

@ -28,8 +28,17 @@ func (input *Input) setError(err error) {
}
func (input *Input) FillValue(r *http.Request) {
input.Val = strings.TrimSpace(r.FormValue(input.Name))
input.FillValueIndex(r, 0)
}
func (input *Input) FillValueIndex(r *http.Request, idx int) {
var val string
if vs := r.Form[input.Name]; len(vs) > idx {
val = vs[idx]
}
input.Val = strings.TrimSpace(val)
}
func (input *Input) Value() (driver.Value, error) {
return input.Val, nil
}

32
pkg/form/month.go Normal file
View File

@ -0,0 +1,32 @@
package form
import (
"fmt"
"net/http"
"strconv"
"time"
)
type Month struct {
Name string
Year int
Month time.Month
Error error
}
func (input *Month) FillValue(r *http.Request) {
if year, err := strconv.Atoi(r.FormValue(input.Name + ".year")); err == nil {
input.Year = year
} else {
fmt.Println(err, r.FormValue(input.Name+".year"))
}
if month, err := strconv.Atoi(r.FormValue(input.Name + ".month")); err == nil && month > 0 && month < 13 {
input.Month = time.Month(month)
} else {
fmt.Println(err, r.FormValue(input.Name+".month"))
}
}
func (input *Month) Date() time.Time {
return time.Date(input.Year, input.Month, 1, 0, 0, 0, 0, time.UTC)
}

View File

@ -8,6 +8,7 @@ package form
import (
"context"
"database/sql/driver"
"dev.tandem.ws/tandem/camper/pkg/locale"
"net/http"
"strconv"
@ -48,7 +49,18 @@ func (s *Select) FillValue(r *http.Request) {
s.Selected = r.Form[s.Name]
}
func (s *Select) FillValueIndex(r *http.Request, idx int) {
if vs := r.Form[s.Name]; len(vs) > idx {
s.Selected = []string{vs[idx]}
} else {
s.Selected = nil
}
}
func (s *Select) ValidOptionsSelected() bool {
if len(s.Selected) == 0 {
return false
}
for _, selected := range s.Selected {
if !s.isValidOption(selected) {
return false
@ -102,3 +114,11 @@ func MustGetOptions(ctx context.Context, conn *database.Conn, sql string, args .
return options
}
func MustGetCountryOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*Option {
return MustGetOptions(ctx, conn, "select country.country_code, coalesce(i18n.name, country.name) as l10n_name from country left join country_i18n as i18n on country.country_code = i18n.country_code and i18n.lang_tag = $1 order by l10n_name", l.Language)
}
func MustGetDocumentTypeOptions(ctx context.Context, conn *database.Conn, l *locale.Locale) []*Option {
return MustGetOptions(ctx, conn, "select idt.id_document_type_id::text, coalesce(i18n.name, idt.name) as l10n_name from id_document_type as idt left join id_document_type_i18n as i18n on idt.id_document_type_id = i18n.id_document_type_id and i18n.lang_tag = $1 order by l10n_name", l.Language)
}

View File

@ -11,9 +11,10 @@ import (
)
const (
HxLocation = "HX-Location"
HxRedirect = "HX-Redirect"
HxRequest = "HX-Request"
HxLocation = "HX-Location"
HxRedirect = "HX-Redirect"
HxRequest = "HX-Request"
HxTriggerAfterSettle = "HX-Trigger-After-Settle"
)
func Relocate(w http.ResponseWriter, r *http.Request, url string, code int) {
@ -37,6 +38,10 @@ func Redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
}
}
func TriggerAfterSettle(w http.ResponseWriter, trigger string) {
w.Header().Set(HxTriggerAfterSettle, trigger)
}
func IsHTMxRequest(r *http.Request) bool {
return r.Header.Get(HxRequest) == "true"
}

1270
pkg/invoice/admin.go Normal file

File diff suppressed because it is too large Load Diff

124
pkg/invoice/filter.go Normal file
View File

@ -0,0 +1,124 @@
package invoice
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/form"
"dev.tandem.ws/tandem/camper/pkg/locale"
)
type filterForm struct {
company *auth.Company
Customer *form.Select
InvoiceStatus *form.Select
InvoiceNumber *form.Input
FromDate *form.Input
ToDate *form.Input
Cursor *form.Cursor
}
func newFilterForm(ctx context.Context, conn *database.Conn, company *auth.Company, locale *locale.Locale) *filterForm {
return &filterForm{
company: company,
Customer: &form.Select{
Name: "customer",
Options: mustGetContactOptions(ctx, conn, company),
},
InvoiceStatus: &form.Select{
Name: "invoice_status",
Options: mustGetInvoiceStatusOptions(ctx, conn, locale),
},
InvoiceNumber: &form.Input{
Name: "number",
},
FromDate: &form.Input{
Name: "from_date",
},
ToDate: &form.Input{
Name: "to_date",
},
Cursor: &form.Cursor{
Name: "cursor",
PerPage: 25,
},
}
}
func (f *filterForm) Parse(r *http.Request) error {
if err := r.ParseForm(); err != nil {
return err
}
f.Customer.FillValue(r)
f.InvoiceStatus.FillValue(r)
f.InvoiceNumber.FillValue(r)
f.FromDate.FillValue(r)
f.ToDate.FillValue(r)
f.Cursor.FillValue(r)
return nil
}
func (f *filterForm) BuildQuery(args []interface{}) (string, []interface{}) {
var where []string
appendWhere := func(expression string, value interface{}) {
args = append(args, value)
where = append(where, fmt.Sprintf(expression, len(args)))
}
maybeAppendWhere := func(expression string, value string, conv func(string) interface{}) {
if value != "" {
if conv == nil {
appendWhere(expression, value)
} else {
appendWhere(expression, conv(value))
}
}
}
appendWhere("invoice.company_id = $%d", f.company.ID)
maybeAppendWhere("contact_id = $%d", f.Customer.String(), func(v string) interface{} {
customerId, _ := strconv.Atoi(f.Customer.Selected[0])
return customerId
})
maybeAppendWhere("invoice.invoice_status = $%d", f.InvoiceStatus.String(), nil)
maybeAppendWhere("invoice_number = $%d", f.InvoiceNumber.Val, nil)
maybeAppendWhere("invoice_date >= $%d", f.FromDate.Val, nil)
maybeAppendWhere("invoice_date <= $%d", f.ToDate.Val, nil)
if f.Paginated() {
params := f.Cursor.Params()
if len(params) == 2 {
where = append(where, fmt.Sprintf("(invoice_date, invoice_number) < ($%d, $%d)", len(args)+1, len(args)+2))
args = append(args, params[0])
args = append(args, params[1])
}
}
return strings.Join(where, ") AND ("), args
}
func (f *filterForm) buildCursor(customers []*IndexEntry) []*IndexEntry {
return form.BuildCursor(f.Cursor, customers, func(entry *IndexEntry) []string {
return []string{entry.Date.Format(database.ISODateFormat), entry.Number}
})
}
func (f *filterForm) HasValue() bool {
return (len(f.Customer.Selected) > 0 && f.Customer.Selected[0] != "") ||
(len(f.InvoiceStatus.Selected) > 0 && f.InvoiceStatus.Selected[0] != "") ||
f.InvoiceNumber.Val != "" ||
f.FromDate.Val != "" ||
f.ToDate.Val != ""
}
func (f *filterForm) PerPage() int {
return f.Cursor.PerPage
}
func (f *filterForm) Paginated() bool {
return f.Cursor.Pagination
}

65
pkg/invoice/ods.go Normal file
View File

@ -0,0 +1,65 @@
package invoice
import (
"sort"
"strings"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/locale"
"dev.tandem.ws/tandem/camper/pkg/ods"
)
func mustWriteInvoicesOds(invoices []*IndexEntry, taxes map[int]taxMap, taxColumns map[int]string, company *auth.Company, locale *locale.Locale) []byte {
taxIDs := extractTaxIDs(taxColumns)
columns := make([]string, 6+len(taxIDs))
columns[0] = "Date"
columns[1] = "Invoice Num."
columns[2] = "Customer"
columns[3] = "Status"
i := 4
for _, taxID := range taxIDs {
columns[i] = taxColumns[taxID]
i++
}
columns[i] = "Amount"
table, err := ods.WriteTable(invoices, columns, locale, func(sb *strings.Builder, invoice *IndexEntry) error {
ods.WriteCellDate(sb, invoice.Date)
if err := ods.WriteCellString(sb, invoice.Number); err != nil {
return err
}
if err := ods.WriteCellString(sb, invoice.CustomerName); err != nil {
return err
}
if err := ods.WriteCellString(sb, invoice.StatusLabel); err != nil {
return err
}
writeTaxes(sb, taxes[invoice.ID], taxIDs, company, locale)
ods.WriteCellFloat(sb, invoice.Total, company, locale)
return nil
})
if err != nil {
panic(err)
}
return table
}
func extractTaxIDs(taxColumns map[int]string) []int {
taxIDs := make([]int, len(taxColumns))
i := 0
for k := range taxColumns {
taxIDs[i] = k
i++
}
sort.Ints(taxIDs[:])
return taxIDs
}
func writeTaxes(sb *strings.Builder, taxes taxMap, taxIDs []int, company *auth.Company, locale *locale.Locale) {
for _, taxID := range taxIDs {
var amount string
if taxes != nil {
amount = taxes[taxID]
}
ods.WriteCellFloat(sb, amount, company, locale)
}
}

75
pkg/invoice/pdf.go Normal file
View File

@ -0,0 +1,75 @@
package invoice
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"dev.tandem.ws/tandem/camper/pkg/auth"
"dev.tandem.ws/tandem/camper/pkg/database"
"dev.tandem.ws/tandem/camper/pkg/template"
)
func mustWriteInvoicesPdf(r *http.Request, user *auth.User, company *auth.Company, conn *database.Conn, slugs []string) []byte {
buf := new(bytes.Buffer)
w := zip.NewWriter(buf)
for _, slug := range slugs {
inv := mustGetInvoice(r.Context(), conn, company, slug)
if inv == nil {
continue
}
f, err := w.Create(fmt.Sprintf("%s-%s.pdf", inv.Number, template.Slugify(inv.Invoicee.Name)))
if err != nil {
panic(err)
}
mustWriteInvoicePdf(f, r, user, company, inv)
}
mustClose(w)
return buf.Bytes()
}
func mustWriteInvoicePdf(w io.Writer, r *http.Request, user *auth.User, company *auth.Company, inv *invoice) {
cmd := exec.Command("weasyprint", "--stylesheet", "web/static/invoice.css", "-", "-")
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdin, err := cmd.StdinPipe()
if err != nil {
panic(err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
panic(err)
}
defer func() {
err := stdout.Close()
if !errors.Is(err, os.ErrClosed) {
panic(err)
}
}()
if err = cmd.Start(); err != nil {
panic(err)
}
go func() {
defer mustClose(stdin)
template.MustRenderAdmin(stdin, r, user, company, "invoice/view.gohtml", inv)
}()
if _, err = io.Copy(w, stdout); err != nil {
panic(err)
}
if err := cmd.Wait(); err != nil {
log.Printf("ERR - %v\n", stderr.String())
panic(err)
}
}
func mustClose(closer io.Closer) {
if err := closer.Close(); err != nil {
panic(err)
}
}

Some files were not shown because too many files have changed in this diff Show More