-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbilling.go
More file actions
585 lines (501 loc) · 23.9 KB
/
Copy pathbilling.go
File metadata and controls
585 lines (501 loc) · 23.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
package api
import (
"context"
"mime/multipart"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/asaskevich/govalidator"
"github.com/moonrhythm/validator"
)
type Billing interface {
// Create requires authentication only (no specific permission; creates a billing account owned by the caller).
Create(ctx context.Context, m *BillingCreate) (*BillingCreateResult, error)
// List requires authentication only (no specific permission; lists the caller's own billing accounts).
List(ctx context.Context, m *Empty) (*BillingListResult, error)
// Delete requires ownership of the billing account (the caller must be its owner).
Delete(ctx context.Context, m *BillingDelete) (*Empty, error)
// Get requires ownership of the billing account (the caller must be its owner).
Get(ctx context.Context, m *BillingGet) (*BillingItem, error)
// Update requires ownership of the billing account (the caller must be its owner).
Update(ctx context.Context, m *BillingUpdate) (*Empty, error)
// Report requires ownership of the billing account (the caller must be its owner).
Report(ctx context.Context, m *BillingReport) (*BillingReportResult, error)
// SKUs requires no authentication (public static price list).
SKUs(ctx context.Context, m *Empty) (*BillingSKUs, error)
// Project requires the `project.get` permission.
Project(ctx context.Context, m *BillingProject) (*BillingProjectResult, error)
// ListInvoices requires ownership of the billing account (the caller must own the account).
ListInvoices(ctx context.Context, m *InvoiceList) (*InvoiceListResult, error)
// GetInvoice requires ownership of the invoice's billing account (the caller must own it).
GetInvoice(ctx context.Context, m *InvoiceGet) (*InvoiceItem, error)
// DownloadInvoice requires ownership of the invoice's billing account (enforced via GetInvoice).
DownloadInvoice(ctx context.Context, m *InvoiceGet) (*InvoiceDownloadResult, error)
// DownloadReceipt renders the receipt / tax-invoice PDF for a PAID invoice.
// Calling it on a draft/open/void invoice returns ErrInvoiceNotPaid.
// DownloadReceipt requires ownership of the invoice's billing account (enforced via GetInvoice).
DownloadReceipt(ctx context.Context, m *InvoiceGet) (*InvoiceDownloadResult, error)
// UploadTransferSlip requires ownership of the invoice's billing account (enforced via GetInvoice).
UploadTransferSlip(ctx context.Context, m *InvoiceUploadSlip) (*InvoiceUploadSlipResult, error)
// UploadWHTCertificate attaches a withholding-tax certificate (50 ทวิ) to an
// invoice after the fact — for customers who send the cert later than the
// payment slip. It attaches to the invoice's latest transfer slip and is
// allowed even after the invoice is paid. Company invoices only; requires
// ownership of the invoice's billing account (enforced via GetInvoice).
UploadWHTCertificate(ctx context.Context, m *InvoiceUploadWHTCert) (*InvoiceUploadWHTCertResult, error)
// ListMembers lists the invited members of a billing account.
// Requires the caller to be the account's owner or an admin member.
ListMembers(ctx context.Context, m *BillingMemberList) (*BillingMemberListResult, error)
// AddMember invites a user to a billing account (or changes an existing
// member's role). Requires the caller to be the account's owner or an admin
// member. The owner cannot be added as a member.
AddMember(ctx context.Context, m *BillingMemberAdd) (*Empty, error)
// RemoveMember removes an invited member from a billing account.
// Requires the caller to be the account's owner or an admin member.
RemoveMember(ctx context.Context, m *BillingMemberRemove) (*Empty, error)
}
// Billing account roles. The owner is implicit (the billing_accounts.owner
// column) and is never stored as a member row. Invited members hold one of the
// two member roles below.
const (
// BillingRoleOwner is the account's sole owner: full control, including
// deleting the account and managing members. Reported by Get/List for the
// caller's own access; never a stored member role.
BillingRoleOwner = "owner"
// BillingRoleAdmin is a full co-manager: view + pay invoices, edit the
// account's tax details, and manage members. Cannot delete the account.
BillingRoleAdmin = "admin"
// BillingRoleAccountant can view invoices/receipts, view the usage report,
// and pay (upload a transfer slip). Cannot edit tax details, delete the
// account, or manage members.
BillingRoleAccountant = "accountant"
)
// IsValidBillingMemberRole reports whether role is a role that can be assigned
// to an invited member (owner is implicit and not assignable).
func IsValidBillingMemberRole(role string) bool {
return role == BillingRoleAdmin || role == BillingRoleAccountant
}
// Billing account entity types. A billing account is either an Individual
// (บุคคลธรรมดา) or a Company (นิติบุคคล). The distinction is a Thai tax-document
// requirement: a juristic person must show the "Head Office" (สำนักงานใหญ่)
// branch designation on its tax invoices, so a Company account prints that line
// on the invoice/receipt while an Individual does not. Empty normalizes to
// Individual (the safe default for accounts that predate this field).
const (
BillingTypeIndividual = "individual"
BillingTypeCompany = "company"
)
// normalizeBillingType trims and defaults an entity type, mapping "" to
// Individual. It returns the normalized value; the caller validates membership.
func normalizeBillingType(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return BillingTypeIndividual
}
return t
}
type BillingCreate struct {
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"`
TaxID string `json:"taxId" yaml:"taxId"`
TaxName string `json:"taxName" yaml:"taxName"`
TaxAddress string `json:"taxAddress" yaml:"taxAddress"`
}
func (m *BillingCreate) Valid() error {
m.Name = strings.TrimSpace(m.Name)
m.Type = normalizeBillingType(m.Type)
m.TaxID = strings.TrimSpace(m.TaxID)
m.TaxName = strings.TrimSpace(m.TaxName)
m.TaxAddress = strings.TrimSpace(m.TaxAddress)
v := validator.New()
if ok := v.Must(m.Name != "", "name required"); ok {
cnt := utf8.RuneCountInString(m.Name)
v.Mustf(cnt >= MinNameLength && cnt <= MaxNameLength, "name must have length between %d-%d characters", MinNameLength, MaxNameLength)
}
v.Must(m.Type == BillingTypeIndividual || m.Type == BillingTypeCompany, "type must be individual or company")
v.Must(m.TaxID != "", "tax id required")
v.Must(utf8.RuneCountInString(m.TaxID) < 100, "tax id too long")
v.Must(m.TaxName != "", "tax name required")
v.Must(utf8.RuneCountInString(m.TaxName) < 200, "tax name too long")
v.Must(m.TaxAddress != "", "tax address required")
v.Must(utf8.RuneCountInString(m.TaxAddress) < 500, "tax address too long")
return WrapValidate(v)
}
type BillingCreateResult struct {
ID int64 `json:"id,string" yaml:"id"`
}
type BillingListResult struct {
Items []*BillingItem `json:"items" yaml:"items"`
}
type BillingDelete struct {
ID int64 `json:"id,string" yaml:"id"`
}
func (m *BillingDelete) Valid() error {
v := validator.New()
v.Must(m.ID > 0, "id required")
return WrapValidate(v)
}
type BillingGet struct {
ID int64 `json:"id,string" yaml:"id"`
}
func (m *BillingGet) Valid() error {
v := validator.New()
v.Must(m.ID > 0, "id required")
return WrapValidate(v)
}
type BillingItem struct {
ID int64 `json:"id,string" yaml:"id"`
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"`
TaxID string `json:"taxId" yaml:"taxId"`
TaxName string `json:"taxName" yaml:"taxName"`
TaxAddress string `json:"taxAddress" yaml:"taxAddress"`
Active bool `json:"active" yaml:"active"`
// Role is the calling user's effective role on this account
// (owner|admin|accountant), so a client can gate management UI without a
// second lookup. Empty on responses that predate membership.
Role string `json:"role" yaml:"role"`
}
type BillingUpdate struct {
ID int64 `json:"id,string" yaml:"id"`
Name string `json:"name" yaml:"name"`
Type string `json:"type" yaml:"type"`
TaxID string `json:"taxId" yaml:"taxId"`
TaxName string `json:"taxName" yaml:"taxName"`
TaxAddress string `json:"taxAddress" yaml:"taxAddress"`
}
func (m *BillingUpdate) Valid() error {
m.Name = strings.TrimSpace(m.Name)
m.Type = normalizeBillingType(m.Type)
m.TaxID = strings.TrimSpace(m.TaxID)
m.TaxName = strings.TrimSpace(m.TaxName)
m.TaxAddress = strings.TrimSpace(m.TaxAddress)
v := validator.New()
if ok := v.Must(m.Name != "", "name required"); ok {
cnt := utf8.RuneCountInString(m.Name)
v.Mustf(cnt >= MinNameLength && cnt <= MaxNameLength, "name must have length between %d-%d characters", MinNameLength, MaxNameLength)
}
v.Must(m.Type == BillingTypeIndividual || m.Type == BillingTypeCompany, "type must be individual or company")
v.Must(m.TaxID != "", "tax id required")
v.Must(utf8.RuneCountInString(m.TaxID) < 100, "tax id too long")
v.Must(m.TaxName != "", "tax name required")
v.Must(utf8.RuneCountInString(m.TaxName) < 200, "tax name too long")
v.Must(m.TaxAddress != "", "tax address required")
v.Must(utf8.RuneCountInString(m.TaxAddress) < 500, "tax address too long")
return WrapValidate(v)
}
type BillingReport struct {
ID int64 `json:"id,string" yaml:"id"`
Range string `json:"range" yaml:"range"`
ProjectSIDs []string `json:"projectSids" yaml:"projectSids"`
}
type BillingReportListItem struct {
ProjectSID string `json:"projectSid" yaml:"projectSid"`
Name string `json:"name" yaml:"name"`
UsageValue float64 `json:"usageValue" yaml:"usageValue"`
BillingValue float64 `json:"billingValue" yaml:"billingValue"`
}
type BillingReportChartSeries struct {
Name string `json:"name" yaml:"name"`
Data []float64 `json:"data" yaml:"data"`
}
type BillingReportChart struct {
Categories []string `json:"categories" yaml:"categories"`
Series []*BillingReportChartSeries `json:"series" yaml:"series"`
}
type ReportProjectListItem struct {
SID string `json:"sid" yaml:"sid"`
Name string `json:"name" yaml:"name"`
}
type BillingReportResult struct {
Range string `json:"range" yaml:"range"`
List []*BillingReportListItem `json:"list" yaml:"list"`
Chart *BillingReportChart `json:"chart" yaml:"chart"`
ProjectList []*ReportProjectListItem `json:"projectList" yaml:"projectList"`
ProjectSIDs []string `json:"projectSids" yaml:"projectSids"`
}
type BillingSKUs struct {
CPUUsage float64 `json:"cpuUsage" yaml:"cpuUsage"`
CPU float64 `json:"cpu" yaml:"cpu"`
Memory float64 `json:"memory" yaml:"memory"`
Egress float64 `json:"egress" yaml:"egress"`
RegistryEgress float64 `json:"registryEgress" yaml:"registryEgress"`
DropboxEgress float64 `json:"dropboxEgress" yaml:"dropboxEgress"`
Disk float64 `json:"disk" yaml:"disk"`
Replica float64 `json:"replica" yaml:"replica"`
StaticStorage float64 `json:"staticStorage" yaml:"staticStorage"`
StaticEgress float64 `json:"staticEgress" yaml:"staticEgress"`
DropboxStorage float64 `json:"dropboxStorage" yaml:"dropboxStorage"`
}
type BillingProject struct {
Project string `json:"project" yaml:"project"`
}
type BillingProjectResult struct {
Price float64 `json:"price" yaml:"price"`
}
type InvoiceList struct {
BillingAccountID int64 `json:"billingAccountId,string" yaml:"billingAccountId"`
}
func (m *InvoiceList) Valid() error {
v := validator.New()
v.Must(m.BillingAccountID > 0, "billingAccountId required")
return WrapValidate(v)
}
type InvoiceListItem struct {
ID int64 `json:"id,string" yaml:"id"`
Number string `json:"number" yaml:"number"`
// ReceiptNumber is the receipt's own running number (DPLY-RC-YYYYMM-NNNN),
// assigned when the invoice is marked paid and distinct from Number. Empty
// until paid.
ReceiptNumber string `json:"receiptNumber" yaml:"receiptNumber"`
Currency string `json:"currency" yaml:"currency"`
PeriodStart time.Time `json:"periodStart" yaml:"periodStart"`
PeriodEnd time.Time `json:"periodEnd" yaml:"periodEnd"`
Subtotal float64 `json:"subtotal" yaml:"subtotal"`
TaxAmount float64 `json:"taxAmount" yaml:"taxAmount"`
Total float64 `json:"total" yaml:"total"`
Status string `json:"status" yaml:"status"`
IssuedAt time.Time `json:"issuedAt" yaml:"issuedAt"`
PaidAt time.Time `json:"paidAt" yaml:"paidAt"`
VoidedAt time.Time `json:"voidedAt" yaml:"voidedAt"`
CreatedAt time.Time `json:"createdAt" yaml:"createdAt"`
}
type InvoiceListResult struct {
Items []*InvoiceListItem `json:"items" yaml:"items"`
}
type InvoiceGet struct {
// InvoiceID rather than ID: this is a billing-module request, where a bare
// "id" would be ambiguous with the billing-account id.
InvoiceID int64 `json:"invoiceId,string" yaml:"invoiceId"`
}
func (m *InvoiceGet) Valid() error {
v := validator.New()
v.Must(m.InvoiceID > 0, "invoiceId required")
return WrapValidate(v)
}
// InvoiceLineItem is one summary line on an invoice: a single project's total
// charge for the billing period. Invoices are grouped by project (not by SKU),
// so the per-resource breakdown does not appear here — only the project's
// aggregate Amount. ProjectID / Project / Description are snapshotted at issue
// time so the line stays stable even if the project is later renamed or deleted.
type InvoiceLineItem struct {
// ProjectID is the billed project's id, snapshotted at issue time.
ProjectID int64 `json:"projectId,string" yaml:"projectId"`
// Project is the project's sid (stable slug), snapshotted at issue time.
Project string `json:"project" yaml:"project"`
// Description is the project's display name at issue time (falls back to the
// sid when the project has no name).
Description string `json:"description" yaml:"description"`
// Amount is the project's total charge for the period (gross, VAT-inclusive).
Amount float64 `json:"amount" yaml:"amount"`
}
// WithholdingTaxRate is the Thai service withholding-tax rate (ภาษีหัก ณ ที่จ่าย),
// 3%. It is the only rate this platform applies: a company buyer may deduct it
// from the pre-VAT service amount when paying. Shared by the server (which
// computes the withheld amount and stamps it on the invoice) and clients (which
// preview the net transfer). The per-invoice InvoiceItem.WithholdingTaxRate is
// the rate actually applied, snapshotted at payment.
const WithholdingTaxRate = 0.03
type InvoiceItem struct {
ID int64 `json:"id,string" yaml:"id"`
BillingAccountID int64 `json:"billingAccountId,string" yaml:"billingAccountId"`
Number string `json:"number" yaml:"number"`
// ReceiptNumber is the receipt's own running number (DPLY-RC-YYYYMM-NNNN),
// assigned when the invoice is marked paid and independent of Number. It is
// what the receipt / tax-invoice PDF carries as its document number; empty
// until the invoice is paid.
ReceiptNumber string `json:"receiptNumber" yaml:"receiptNumber"`
Currency string `json:"currency" yaml:"currency"`
PeriodStart time.Time `json:"periodStart" yaml:"periodStart"`
PeriodEnd time.Time `json:"periodEnd" yaml:"periodEnd"`
Subtotal float64 `json:"subtotal" yaml:"subtotal"`
TaxRate float64 `json:"taxRate" yaml:"taxRate"`
TaxAmount float64 `json:"taxAmount" yaml:"taxAmount"`
Total float64 `json:"total" yaml:"total"`
Status string `json:"status" yaml:"status"`
TaxID string `json:"taxId" yaml:"taxId"`
TaxName string `json:"taxName" yaml:"taxName"`
TaxAddress string `json:"taxAddress" yaml:"taxAddress"`
// TaxEntityType is the buyer's entity type (individual|company) snapshotted
// from the billing account at issue time. A "company" prints the Head Office
// (สำนักงานใหญ่) branch designation on the tax document; empty/individual does
// not.
TaxEntityType string `json:"taxEntityType" yaml:"taxEntityType"`
// WithholdingTaxRate / WithholdingTaxAmount record a withholding tax the buyer
// deducted when paying (Thai ภาษีหัก ณ ที่จ่าย). Only a company buyer may
// withhold; the amount is round(Subtotal × WithholdingTaxRate) — computed off
// the pre-VAT service amount, not the gross Total. Both are 0 until the invoice
// is paid with withholding, and 0 for a normal payment. The invoice is settled
// in full when PaidAmount + WithholdingTaxAmount ≥ Total: the withheld tax is
// remitted to the Revenue Department on the seller's behalf, not a shortfall.
WithholdingTaxRate float64 `json:"withholdingTaxRate" yaml:"withholdingTaxRate"`
WithholdingTaxAmount float64 `json:"withholdingTaxAmount" yaml:"withholdingTaxAmount"`
IssuedAt time.Time `json:"issuedAt" yaml:"issuedAt"`
PaidAt time.Time `json:"paidAt" yaml:"paidAt"`
VoidedAt time.Time `json:"voidedAt" yaml:"voidedAt"`
CreatedAt time.Time `json:"createdAt" yaml:"createdAt"`
LineItems []*InvoiceLineItem `json:"lineItems" yaml:"lineItems"`
// Payment is how a customer settles this invoice — the seller's bank-transfer
// and PromptPay details. These are static seller facts (identical on every
// invoice), surfaced here so a client can show "how to pay" without rendering
// the PDF. The server is the single source of truth.
Payment InvoicePayment `json:"payment" yaml:"payment"`
}
// InvoicePayment is the seller's settlement details shown on an invoice: where
// to send a bank transfer and the PromptPay handle. It is static company info,
// not per-invoice data, so the same values appear on every invoice.
type InvoicePayment struct {
Bank string `json:"bank" yaml:"bank"`
AccountName string `json:"accountName" yaml:"accountName"`
AccountNo string `json:"accountNo" yaml:"accountNo"`
PromptPay string `json:"promptPay" yaml:"promptPay"`
}
// InvoiceDownloadResult points at a rendered PDF copy of an invoice. The PDF
// is uploaded to the dropbox service, so DownloadURL is a short-lived signed
// link that expires at ExpiresAt — callers should fetch it promptly rather
// than storing it.
type InvoiceDownloadResult struct {
DownloadURL string `json:"downloadUrl" yaml:"downloadUrl"`
ExpiresAt time.Time `json:"expiresAt" yaml:"expiresAt"`
}
// MaxTransferSlipSize caps an uploaded payment slip. Slips are photos or PDF
// scans of a bank transfer; 10 MiB is generous for either.
const MaxTransferSlipSize = 10 << 20
// MaxWHTCertSize caps an optional withholding-tax certificate (50 ทวิ) uploaded
// alongside the slip — same 10 MiB budget as the slip (photo or PDF scan).
const MaxWHTCertSize = 10 << 20
// InvoiceUploadSlip carries a customer's proof-of-payment (bank transfer slip)
// for an invoice. It is a multipart upload: the invoice id in the `id` form
// field and the file in the `slip` file field.
//
// WithholdingTax is the buyer's declaration that they deducted 3% withholding
// tax and therefore transferred Total − WHT (see WithholdingTaxRate). Only a
// company buyer may withhold; the server ignores the flag for other invoices.
// WHTCert is the optional withholding-tax certificate (50 ทวิ) in the `whtCert`
// file field — customers may attach it here or provide it out-of-band later.
type InvoiceUploadSlip struct {
ID int64
File *multipart.FileHeader
WithholdingTax bool
WHTCert *multipart.FileHeader
}
func (m *InvoiceUploadSlip) UnmarshalMultipartForm(v *multipart.Form) error {
if ids := v.Value["id"]; len(ids) == 1 {
m.ID, _ = strconv.ParseInt(ids[0], 10, 64)
}
if fps := v.File["slip"]; len(fps) == 1 {
m.File = fps[0]
}
if ws := v.Value["withholdingTax"]; len(ws) == 1 {
m.WithholdingTax, _ = strconv.ParseBool(ws[0])
}
if fps := v.File["whtCert"]; len(fps) == 1 {
m.WHTCert = fps[0]
}
return nil
}
func (m *InvoiceUploadSlip) Valid() error {
v := validator.New()
v.Must(m.ID > 0, "id required")
if ok := v.Must(m.File != nil, "slip required"); ok {
v.Must(m.File.Size > 0, "slip required")
v.Must(m.File.Size <= MaxTransferSlipSize, "slip too large")
}
// The certificate is optional; validate it only when attached.
if m.WHTCert != nil {
v.Must(m.WHTCert.Size > 0, "withholding tax certificate required")
v.Must(m.WHTCert.Size <= MaxWHTCertSize, "withholding tax certificate too large")
}
return WrapValidate(v)
}
type InvoiceUploadSlipResult struct {
DownloadURL string `json:"downloadUrl" yaml:"downloadUrl"`
ExpiresAt time.Time `json:"expiresAt" yaml:"expiresAt"`
}
// InvoiceUploadWHTCert carries a withholding-tax certificate (50 ทวิ) uploaded
// on its own after payment, attaching to the invoice's latest transfer slip. It
// is a multipart upload: the invoice id in the `id` form field and the file in
// the `whtCert` file field.
type InvoiceUploadWHTCert struct {
ID int64
WHTCert *multipart.FileHeader
}
func (m *InvoiceUploadWHTCert) UnmarshalMultipartForm(v *multipart.Form) error {
if ids := v.Value["id"]; len(ids) == 1 {
m.ID, _ = strconv.ParseInt(ids[0], 10, 64)
}
if fps := v.File["whtCert"]; len(fps) == 1 {
m.WHTCert = fps[0]
}
return nil
}
func (m *InvoiceUploadWHTCert) Valid() error {
v := validator.New()
v.Must(m.ID > 0, "id required")
if ok := v.Must(m.WHTCert != nil, "withholding tax certificate required"); ok {
v.Must(m.WHTCert.Size > 0, "withholding tax certificate required")
v.Must(m.WHTCert.Size <= MaxWHTCertSize, "withholding tax certificate too large")
}
return WrapValidate(v)
}
type InvoiceUploadWHTCertResult struct {
DownloadURL string `json:"downloadUrl" yaml:"downloadUrl"`
ExpiresAt time.Time `json:"expiresAt" yaml:"expiresAt"`
}
// BillingMember is an invited (non-owner) user on a billing account.
type BillingMember struct {
Email string `json:"email" yaml:"email"`
// Role is the member's role: "admin" or "accountant".
Role string `json:"role" yaml:"role"`
CreatedAt time.Time `json:"createdAt" yaml:"createdAt"`
// CreatedBy is the email of whoever added the member (attribution).
CreatedBy string `json:"createdBy" yaml:"createdBy"`
}
type BillingMemberList struct {
ID int64 `json:"id,string" yaml:"id"` // billing account id
}
func (m *BillingMemberList) Valid() error {
v := validator.New()
v.Must(m.ID > 0, "id required")
return WrapValidate(v)
}
type BillingMemberListResult struct {
// Owner is the account's owner email — the implicit "owner" role, listed
// alongside members so a client can render the full access list.
Owner string `json:"owner" yaml:"owner"`
Items []*BillingMember `json:"items" yaml:"items"`
}
type BillingMemberAdd struct {
ID int64 `json:"id,string" yaml:"id"` // billing account id
Email string `json:"email" yaml:"email"`
Role string `json:"role" yaml:"role"` // "admin" or "accountant"
}
func (m *BillingMemberAdd) Valid() error {
// Canonicalize to lower-case: the invitee is matched against the email their
// identity provider hands us (canonical lower-case), so a mixed-case invite
// like "Bob@Example.com" must resolve to the same member row, not lock them out.
m.Email = strings.ToLower(strings.TrimSpace(m.Email))
m.Role = strings.TrimSpace(m.Role)
v := validator.New()
v.Must(m.ID > 0, "id required")
v.Must(m.Email != "", "email required")
// IsEmail also rejects allUsers / allAuthenticatedUsers: billing is money,
// so a member must be a real, addressable identity — no public principals.
v.Must(govalidator.IsEmail(m.Email), "email invalid")
v.Must(IsValidBillingMemberRole(m.Role), "role must be admin or accountant")
return WrapValidate(v)
}
type BillingMemberRemove struct {
ID int64 `json:"id,string" yaml:"id"` // billing account id
Email string `json:"email" yaml:"email"`
}
func (m *BillingMemberRemove) Valid() error {
// Match the canonicalization AddMember applies, so a member added as
// "Bob@Example.com" (stored lower-case) can be removed by any casing.
m.Email = strings.ToLower(strings.TrimSpace(m.Email))
v := validator.New()
v.Must(m.ID > 0, "id required")
v.Must(m.Email != "", "email required")
return WrapValidate(v)
}