diff --git a/backend/go.mod b/backend/go.mod index c869d3f4..b150de6e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -73,6 +73,7 @@ require ( github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/stripe/stripe-go/v84 v84.3.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/backend/go.sum b/backend/go.sum index 37dc4684..4e7b7458 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -178,6 +178,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v84 v84.3.0 h1:77HH+ro7yzmyyF7Xkbkj6y5QtnU1WWHC6t2y4mq0Wvk= +github.com/stripe/stripe-go/v84 v84.3.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= diff --git a/backend/internal/models/event_occurrence.go b/backend/internal/models/event_occurrence.go index 9bd841a4..d79bda21 100644 --- a/backend/internal/models/event_occurrence.go +++ b/backend/internal/models/event_occurrence.go @@ -13,24 +13,22 @@ const ( EventOccurrenceStatusCancelled EventOccurrenceStatus = "cancelled" ) -// database model for a specific instance of an event -// stores full type information for Event and Location type EventOccurrence struct { - ID uuid.UUID `json:"id" db:"id"` - ManagerId *uuid.UUID `json:"manager_id" db:"manager_id"` - Event Event `json:"event" db:"-"` - Location Location `json:"location" db:"-"` - StartTime time.Time `json:"start_time" db:"start_time"` - EndTime time.Time `json:"end_time" db:"end_time"` - MaxAttendees int `json:"max_attendees" db:"max_attendees"` - Language string `json:"language" db:"language"` - CurrEnrolled int `json:"curr_enrolled" db:"curr_enrolled"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - Status RegistrationStatus `json:"status" db:"status" doc:"Current status of the event occurrence" enum:"scheduled,cancelled"` + ID uuid.UUID `json:"id" db:"id"` + ManagerId *uuid.UUID `json:"manager_id" db:"manager_id"` + Event Event `json:"event" db:"-"` + Location Location `json:"location" db:"-"` + StartTime time.Time `json:"start_time" db:"start_time"` + EndTime time.Time `json:"end_time" db:"end_time"` + MaxAttendees int `json:"max_attendees" db:"max_attendees"` + Language string `json:"language" db:"language"` + CurrEnrolled int `json:"curr_enrolled" db:"curr_enrolled"` + Price int `json:"price" db:"price" doc:"Price in cents (e.g., 10000 = ฿100)"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + Status EventOccurrenceStatus `json:"status" db:"status" doc:"Current status of the event occurrence" enum:"scheduled,cancelled"` } -// get all type GetAllEventOccurrencesInput struct { Page int `query:"page" minimum:"1" default:"1"` Limit int `query:"limit" minimum:"1" maximum:"100" default:"100"` @@ -40,7 +38,6 @@ type GetAllEventOccurrencesOutput struct { Body []EventOccurrence `json:"body" doc:"List of all event occurrences in the database"` } -// get by event occurrence id type GetEventOccurrenceByIDInput struct { ID uuid.UUID `path:"id" doc:"ID of an event occurrence"` } @@ -49,7 +46,6 @@ type GetEventOccurrenceByIDOutput struct { Body *EventOccurrence `json:"body" doc:"Event occurrence in the database that matches the ID"` } -// post type CreateEventOccurrenceInput struct { Body struct { ManagerId *uuid.UUID `json:"manager_id,omitempty" doc:"ID of a manager in the database"` @@ -59,6 +55,7 @@ type CreateEventOccurrenceInput struct { EndTime time.Time `json:"end_time" doc:"End time of the event occurrence"` MaxAttendees int `json:"max_attendees" doc:"Maximum number of attendees" minimum:"1" maximum:"100"` Language string `json:"language" doc:"Primary language used for the event occurrence" minLength:"2" maxLength:"30"` + Price int `json:"price" doc:"Price in cents (e.g., 10000 = ฿100)" minimum:"0"` } `json:"body" doc:"New event occurrence to add"` } @@ -66,7 +63,6 @@ type CreateEventOccurrenceOutput struct { Body *EventOccurrence `json:"body" doc:"Created event occurrence"` } -// patch type UpdateEventOccurrenceInput struct { ID uuid.UUID `path:"id" doc:"ID of the event occurrence to update"` Body struct { @@ -78,6 +74,7 @@ type UpdateEventOccurrenceInput struct { MaxAttendees *int `json:"max_attendees,omitempty" doc:"Maximum number of attendees" minimum:"1" maximum:"100"` Language *string `json:"language,omitempty" doc:"Primary language used for the event occurrence" minLength:"2" maxLength:"30"` CurrEnrolled *int `json:"curr_enrolled,omitempty" doc:"Number of students currently enrolled in the event occurrence" minimum:"0" maximum:"100"` + Price *int `json:"price,omitempty" doc:"Price in cents" minimum:"0"` } `json:"body" doc:"Event occurrence fields to update"` } @@ -93,4 +90,4 @@ type CancelEventOccurrenceOutput struct { Body struct { Message string `json:"message" doc:"Success message"` } `json:"body"` -} +} \ No newline at end of file diff --git a/backend/internal/models/guardian.go b/backend/internal/models/guardian.go index d150bccc..9c7292fe 100644 --- a/backend/internal/models/guardian.go +++ b/backend/internal/models/guardian.go @@ -14,6 +14,7 @@ type Guardian struct { Username string `json:"username" db:"username"` ProfilePictureS3Key *string `json:"profile_picture_s3_key" db:"profile_picture_s3_key"` LanguagePreference string `json:"language_preference" db:"language_preference"` + StripeCustomerID *string `json:"stripe_customer_id,omitempty" db:"stripe_customer_id"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` } @@ -36,11 +37,11 @@ type GetGuardianByChildIDInput struct { type CreateGuardianInput struct { Body struct { - Name string `json:"name" doc:"Name of the guardian"` - Email string `json:"email" doc:"Email of the guardian"` - Username string `json:"username" doc:"Username of the guardian"` - ProfilePictureS3Key *string `json:"profile_picture_s3_key,omitempty" doc:"S3 key for profile picture" required:"false"` - LanguagePreference string `json:"language_preference" doc:"Language preference"` + Name string `json:"name" doc:"Name of the guardian"` + Email string `json:"email" doc:"Email of the guardian"` + Username string `json:"username" doc:"Username of the guardian"` + ProfilePictureS3Key *string `json:"profile_picture_s3_key,omitempty" doc:"S3 key for profile picture" required:"false"` + LanguagePreference string `json:"language_preference" doc:"Language preference"` AuthID *uuid.UUID `json:"auth_id,omitempty" db:"auth_id" doc:"auth id of the guardian being created" required:"false"` } } @@ -71,3 +72,11 @@ type GetGuardianByChildIDOutput struct { type GetGuardianByIDOutput struct { Body *Guardian `json:"body"` } + +type CreateStripeCustomerInput struct { + GuardianID uuid.UUID `path:"guardian_id" doc:"Guardian ID"` +} + +type CreateStripeCustomerOutput struct { + Body Guardian `json:"body" doc:"Updated guardian with Stripe customer ID"` +} \ No newline at end of file diff --git a/backend/internal/models/guardian_payment_method.go b/backend/internal/models/guardian_payment_method.go new file mode 100644 index 00000000..6779313c --- /dev/null +++ b/backend/internal/models/guardian_payment_method.go @@ -0,0 +1,63 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type GuardianPaymentMethod struct { + ID uuid.UUID `json:"id" db:"id"` + GuardianID uuid.UUID `json:"guardian_id" db:"guardian_id"` + StripePaymentMethodID string `json:"stripe_payment_method_id" db:"stripe_payment_method_id"` + CardBrand *string `json:"card_brand,omitempty" db:"card_brand"` + CardLast4 *string `json:"card_last4,omitempty" db:"card_last4"` + CardExpMonth *int `json:"card_exp_month,omitempty" db:"card_exp_month"` + CardExpYear *int `json:"card_exp_year,omitempty" db:"card_exp_year"` + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +type CreateGuardianPaymentMethodInput struct { + Body struct { + GuardianID uuid.UUID `json:"guardian_id" doc:"Guardian ID"` + StripePaymentMethodID string `json:"stripe_payment_method_id" doc:"Stripe payment method ID (pm_...)"` + CardBrand *string `json:"card_brand,omitempty" doc:"Card brand (visa, mastercard, etc.)"` + CardLast4 *string `json:"card_last4,omitempty" doc:"Last 4 digits of card"` + CardExpMonth *int `json:"card_exp_month,omitempty" doc:"Card expiration month" minimum:"1" maximum:"12"` + CardExpYear *int `json:"card_exp_year,omitempty" doc:"Card expiration year" minimum:"2026"` + IsDefault bool `json:"is_default" doc:"Whether this is the default payment method"` + } +} + +type CreateGuardianPaymentMethodOutput struct { + Body GuardianPaymentMethod `json:"body" doc:"Created payment method"` +} + +type GetGuardianPaymentMethodsByGuardianIDInput struct { + GuardianID uuid.UUID `path:"guardian_id" doc:"Guardian ID"` +} + +type GetGuardianPaymentMethodsByGuardianIDOutput struct { + Body []GuardianPaymentMethod `json:"body" doc:"List of guardian's payment methods"` +} + +type DeleteGuardianPaymentMethodInput struct { + ID uuid.UUID `path:"id" doc:"Payment method ID"` +} + +type DeleteGuardianPaymentMethodOutput struct { + Body struct { + Message string `json:"message" doc:"Success message"` + } +} + +type SetDefaultPaymentMethodInput struct { + GuardianID uuid.UUID `path:"guardian_id" doc:"Guardian ID"` + PaymentMethodID uuid.UUID `path:"payment_method_id" doc:"Payment method ID to set as default"` +} + +type SetDefaultPaymentMethodOutput struct { + Body GuardianPaymentMethod `json:"body" doc:"Updated payment method"` +} \ No newline at end of file diff --git a/backend/internal/models/organization.go b/backend/internal/models/organization.go index bbb87446..e4cb71d2 100644 --- a/backend/internal/models/organization.go +++ b/backend/internal/models/organization.go @@ -8,14 +8,16 @@ import ( ) type Organization struct { - ID uuid.UUID `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Active bool `json:"active" db:"active"` - PfpS3Key *string `json:"pfp_s3_key,omitempty" db:"pfp_s3_key"` + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Active bool `json:"active" db:"active"` + PfpS3Key *string `json:"pfp_s3_key,omitempty" db:"pfp_s3_key"` PresignedURL *string `json:"presigned_url"` - LocationID *uuid.UUID `json:"location_id,omitempty" db:"location_id"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + LocationID *uuid.UUID `json:"location_id,omitempty" db:"location_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + StripeAccountID *string `json:"stripe_account_id" db:"stripe_account_id"` + StripeAccountActivated bool `json:"stripe_account_activated" db:"stripe_account_activated" default:"false"` } // CreateOrganizationRouteInput is the multipart form input for creating an organization with an image diff --git a/backend/internal/models/payment.go b/backend/internal/models/payment.go new file mode 100644 index 00000000..427a69de --- /dev/null +++ b/backend/internal/models/payment.go @@ -0,0 +1,109 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "github.com/stripe/stripe-go/v84" +) + +type CreateOrgStripeAccountInput struct { + Body struct { + OrganizationID uuid.UUID `json:"organization_id" doc:"UUID of the existing organization"` + } +} + +type CreateOrgStripeAccountOutput struct { + Body struct { + Account stripe.V2CoreAccount `json:"account" doc:"Stripe account details"` + } +} + +type CreateStripeOnboardingLinkInput struct { + Body struct { + AccountID string `json:"account_id" doc:"Stripe account ID (e.g., acct_123)"` + RefreshURL string `json:"refresh_url" doc:"URL to redirect if onboarding is exited early"` + ReturnURL string `json:"return_url" doc:"URL to redirect after successful onboarding"` + } +} + +type CreateStripeOnboardingLinkOutput struct { + Body struct { + OnboardingURL string `json:"onboarding_url" doc:"Stripe-hosted onboarding page URL"` + } +} + +type CreateSetupIntentInput struct { + GuardianID uuid.UUID `path:"guardian_id" doc:"Guardian ID"` +} + +type CreateSetupIntentOutput struct { + Body struct { + ClientSecret string `json:"client_secret" doc:"Stripe SetupIntent client_secret for frontend"` + } +} + +type CreateOrgLoginLinkInput struct { + OrganizationID uuid.UUID `path:"organization_id" doc:"Organization ID"` +} + +type CreateOrgLoginLinkOutput struct { + Body struct { + LoginURL string `json:"login_url" doc:"Stripe Express dashboard login URL"` + } +} + +type CreatePaymentIntentInput struct { + Body struct { + RegistrationID uuid.UUID `json:"registration_id" doc:"Registration/booking ID"` + GuardianID uuid.UUID `json:"guardian_id" doc:"Guardian ID"` + ProviderOrgID uuid.UUID `json:"provider_org_id" doc:"Provider organization ID"` + Amount int64 `json:"amount" doc:"Total amount in cents" minimum:"1"` + Currency string `json:"currency" doc:"Currency code (e.g., thb, usd)" pattern:"^[a-z]{3}$"` + EventDate time.Time `json:"event_date" doc:"Event date and time"` + PaymentMethodID *string `json:"payment_method_id,omitempty" doc:"Stripe payment method ID (required for bookings)"` + + GuardianStripeID string + OrgStripeID string + } +} + +type CreatePaymentIntentOutput struct { + Body struct { + PaymentIntentID string `json:"payment_intent_id" doc:"Stripe payment intent ID"` + ClientSecret string `json:"client_secret" doc:"Client secret for frontend to confirm payment"` + Status string `json:"status" doc:"Payment intent status"` + TotalAmount int `json:"total_amount" doc:"Total amount in cents"` + ProviderAmount int `json:"provider_amount" doc:"Amount provider receives in cents"` + PlatformFeeAmount int `json:"platform_fee_amount" doc:"Platform fee in cents"` + Currency string `json:"currency" doc:"Currency code"` + } +} + +type CancelPaymentIntentInput struct { + PaymentIntentID string `json:"payment_intent_id" doc:"Stripe payment intent ID to cancel/refund"` + StripeAccountID string `json:"stripe_account_id" doc:"Organization's Stripe account ID"` +} + +type CancelPaymentIntentOutput struct { + Body struct { + PaymentIntentID string `json:"payment_intent_id" doc:"Cancelled payment intent ID"` + Status string `json:"status" doc:"Payment intent status after cancellation"` + Amount int64 `json:"amount" doc:"Amount that was cancelled/refunded in cents"` + Currency string `json:"currency" doc:"Currency code"` + } `json:"body" doc:"Cancellation result"` +} + +type CapturePaymentIntentInput struct { + PaymentIntentID string `json:"payment_intent_id" doc:"Stripe payment intent ID to capture"` + StripeAccountID string `json:"stripe_account_id" doc:"Organization's Stripe account ID"` +} + +type CapturePaymentIntentOutput struct { + Body struct { + PaymentIntentID string `json:"payment_intent_id" doc:"Captured payment intent ID"` + Status string `json:"status" doc:"Payment intent status (should be 'succeeded')"` + Amount int64 `json:"amount" doc:"Amount captured in cents"` + Currency string `json:"currency" doc:"Currency code"` + } `json:"body" doc:"Capture result"` +} \ No newline at end of file diff --git a/backend/internal/models/registration.go b/backend/internal/models/registration.go index 6f5de1f3..4f82028b 100644 --- a/backend/internal/models/registration.go +++ b/backend/internal/models/registration.go @@ -7,15 +7,26 @@ import ( ) type Registration struct { - ID uuid.UUID `json:"id" db:"id" doc:"Unique registration identifier"` - ChildID uuid.UUID `json:"child_id" db:"child_id" doc:"ID of the registered child"` - GuardianID uuid.UUID `json:"guardian_id" db:"guardian_id" doc:"ID of the child's guardian"` - EventOccurrenceID uuid.UUID `json:"event_occurrence_id" db:"event_occurrence_id" doc:"ID of the event occurrence"` - Status RegistrationStatus `json:"status" db:"status" doc:"Current status of the registration" enum:"registered,cancelled"` - CreatedAt time.Time `json:"created_at" db:"created_at" doc:"Timestamp when registration was created"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at" doc:"Timestamp when registration was last updated"` - EventName string `json:"event_name" db:"event_name" doc:"Name of the event (joined from events table)"` - OccurrenceStartTime time.Time `json:"occurrence_start_time" db:"occurrence_start_time" doc:"Start time of the event occurrence"` + ID uuid.UUID `json:"id" db:"id" doc:"Unique registration identifier"` + ChildID uuid.UUID `json:"child_id" db:"child_id" doc:"ID of the registered child"` + GuardianID uuid.UUID `json:"guardian_id" db:"guardian_id" doc:"ID of the child's guardian"` + EventOccurrenceID uuid.UUID `json:"event_occurrence_id" db:"event_occurrence_id" doc:"ID of the event occurrence"` + Status RegistrationStatus `json:"status" db:"status" doc:"Current status of the registration" enum:"registered,cancelled"` + StripePaymentIntentID string `json:"stripe_payment_intent_id" db:"stripe_payment_intent_id" doc:"Stripe payment intent ID"` + StripeCustomerID string `json:"stripe_customer_id" db:"stripe_customer_id" doc:"Stripe customer ID"` + OrgStripeAccountID string `json:"org_stripe_account_id" db:"org_stripe_account_id" doc:"Organization's Stripe account ID"` + StripePaymentMethodID string `json:"stripe_payment_method_id" db:"stripe_payment_method_id" doc:"Stripe payment method ID"` + TotalAmount int `json:"total_amount" db:"total_amount" doc:"Total amount in cents"` + ProviderAmount int `json:"provider_amount" db:"provider_amount" doc:"Amount provider receives in cents"` + PlatformFeeAmount int `json:"platform_fee_amount" db:"platform_fee_amount" doc:"Platform fee amount in cents"` + Currency string `json:"currency" db:"currency" doc:"Currency code (e.g., thb, usd)"` + PaymentIntentStatus string `json:"payment_intent_status" db:"payment_intent_status" doc:"Stripe payment intent status"` + PaidAt *time.Time `json:"paid_at,omitempty" db:"paid_at" doc:"Timestamp when payment was completed"` + CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at" doc:"Timestamp when registration was cancelled"` + CreatedAt time.Time `json:"created_at" db:"created_at" doc:"Timestamp when registration was created"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at" doc:"Timestamp when registration was last updated"` + EventName string `json:"event_name" db:"event_name" doc:"Name of the event"` + OccurrenceStartTime time.Time `json:"occurrence_start_time" db:"occurrence_start_time" doc:"Start time of the event occurrence"` } type RegistrationStatus string @@ -34,21 +45,36 @@ type CreateRegistrationInput struct { ChildID uuid.UUID `json:"child_id" doc:"ID of the child to register" format:"uuid" required:"true"` GuardianID uuid.UUID `json:"guardian_id" doc:"ID of the guardian registering the child" format:"uuid" required:"true"` EventOccurrenceID uuid.UUID `json:"event_occurrence_id" doc:"ID of the event occurrence to register for" format:"uuid" required:"true"` + PaymentMethodID string `json:"payment_method_id" doc:"Stripe payment method ID to use" required:"true"` + Currency string `json:"currency" doc:"Currency code (e.g., thb, usd)" default:"thb"` Status RegistrationStatus `json:"status" doc:"Initial status of the registration" default:"registered" enum:"registered,cancelled"` } `json:"body"` } +type CreateRegistrationWithPaymentData struct { + ChildID uuid.UUID + GuardianID uuid.UUID + EventOccurrenceID uuid.UUID + Status RegistrationStatus + StripePaymentIntentID string + StripeCustomerID string + OrgStripeAccountID string + StripePaymentMethodID string + TotalAmount int + ProviderAmount int + PlatformFeeAmount int + Currency string + PaymentIntentStatus string +} + type CreateRegistrationOutput struct { Body Registration `json:"body" doc:"The newly created registration with full details"` } type UpdateRegistrationInput struct { - ID uuid.UUID `path:"id" format:"uuid" doc:"Registration ID to update" required:"true"` + ID uuid.UUID `path:"id" format:"uuid" doc:"Registration ID to update"` Body struct { - ChildID *uuid.UUID `json:"child_id,omitempty" doc:"Updated child ID (optional)" format:"uuid"` - GuardianID *uuid.UUID `json:"guardian_id,omitempty" doc:"Updated guardian ID (optional)" format:"uuid"` - EventOccurrenceID *uuid.UUID `json:"event_occurrence_id,omitempty" doc:"Updated event occurrence ID (optional)" format:"uuid"` - Status *RegistrationStatus `json:"status,omitempty" doc:"Updated registration status (optional)" enum:"registered,cancelled"` + ChildID uuid.UUID `json:"child_id,omitempty" doc:"Updated child ID (optional)"` } `json:"body"` } @@ -56,6 +82,29 @@ type UpdateRegistrationOutput struct { Body Registration `json:"body" doc:"The updated registration with full details"` } +type CancelRegistrationInput struct { + ID uuid.UUID `path:"id" format:"uuid" doc:"Registration ID to cancel"` +} + +type CancelRegistrationOutput struct { + Body struct { + Message string `json:"message" doc:"Success message"` + RefundStatus string `json:"refund_status,omitempty" doc:"Refund status if applicable"` + Registration Registration `json:"registration" doc:"Updated registration"` + } `json:"body"` +} + +type UpdateRegistrationPaymentStatusInput struct { + ID uuid.UUID `path:"id" format:"uuid" doc:"Registration ID"` + Body struct { + PaymentIntentStatus string `json:"payment_intent_status" doc:"New payment intent status from Stripe"` + } `json:"body"` +} + +type UpdateRegistrationPaymentStatusOutput struct { + Body Registration `json:"body" doc:"The updated registration with full details"` +} + type GetRegistrationByIDInput struct { ID uuid.UUID `path:"id" format:"uuid" doc:"Registration ID to retrieve" required:"true"` } diff --git a/backend/internal/service/handler/guardian-payment-method/createGuardianPaymentMethod.go b/backend/internal/service/handler/guardian-payment-method/createGuardianPaymentMethod.go new file mode 100644 index 00000000..8f8f3dfb --- /dev/null +++ b/backend/internal/service/handler/guardian-payment-method/createGuardianPaymentMethod.go @@ -0,0 +1,33 @@ +package guardianpaymentmethod + +import ( + "context" + "errors" + "skillspark/internal/models" +) + +func (h *Handler) CreateGuardianPaymentMethod( + ctx context.Context, + input *models.CreateGuardianPaymentMethodInput, +) (*models.CreateGuardianPaymentMethodOutput, error) { + + guardian, err := h.GuardianRepository.GetGuardianByID(ctx, input.Body.GuardianID) + if err != nil { + return nil, err + } + + if guardian.StripeCustomerID == nil || *guardian.StripeCustomerID == "" { + return nil, errors.New("guardian must have stripe customer ID") + } + + + + paymentMethod, err := h.GuardianPaymentMethodRepository.CreateGuardianPaymentMethod(ctx, input) + if err != nil { + return nil, err + } + + return &models.CreateGuardianPaymentMethodOutput{ + Body: *paymentMethod, + }, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian-payment-method/deleteGuardianPaymentMethod.go b/backend/internal/service/handler/guardian-payment-method/deleteGuardianPaymentMethod.go new file mode 100644 index 00000000..d78efb37 --- /dev/null +++ b/backend/internal/service/handler/guardian-payment-method/deleteGuardianPaymentMethod.go @@ -0,0 +1,28 @@ +package guardianpaymentmethod + +import ( + "context" + "errors" + "skillspark/internal/models" +) + +func (h *Handler) DeleteGuardianPaymentMethod( + ctx context.Context, + input *models.DeleteGuardianPaymentMethodInput, +) (*models.DeleteGuardianPaymentMethodOutput, error) { + + paymentMethod, err := h.GuardianPaymentMethodRepository.DeleteGuardianPaymentMethod(ctx, input.ID) + if err != nil { + return nil, err + } + + err = h.StripeClient.DetachPaymentMethod(ctx, paymentMethod.StripePaymentMethodID) + if err != nil { + return nil, errors.New("payment method deleted from database but failed to detach from Stripe") + } + + output := &models.DeleteGuardianPaymentMethodOutput{} + output.Body.Message = "Payment method deleted successfully" + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian-payment-method/getGuardianPaymentMethodByGuardianId.go b/backend/internal/service/handler/guardian-payment-method/getGuardianPaymentMethodByGuardianId.go new file mode 100644 index 00000000..a0159590 --- /dev/null +++ b/backend/internal/service/handler/guardian-payment-method/getGuardianPaymentMethodByGuardianId.go @@ -0,0 +1,32 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/models" +) + +func (h *Handler) GetPaymentMethodsByGuardianID( + ctx context.Context, + input *models.GetGuardianPaymentMethodsByGuardianIDInput, +) (*models.GetGuardianPaymentMethodsByGuardianIDOutput, error) { + + guardian, err := h.GuardianRepository.GetGuardianByID(ctx, input.GuardianID) + if err != nil { + return nil, err + } + + if guardian.StripeCustomerID == nil || *guardian.StripeCustomerID == "" { + return &models.GetGuardianPaymentMethodsByGuardianIDOutput{ + Body: []models.GuardianPaymentMethod{}, + }, nil + } + + paymentMethods, err := h.GuardianPaymentMethodRepository.GetPaymentMethodsByGuardianID(ctx, input.GuardianID) + if err != nil { + return nil, err + } + + return &models.GetGuardianPaymentMethodsByGuardianIDOutput{ + Body: paymentMethods, + }, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian-payment-method/handler.go b/backend/internal/service/handler/guardian-payment-method/handler.go new file mode 100644 index 00000000..590953ce --- /dev/null +++ b/backend/internal/service/handler/guardian-payment-method/handler.go @@ -0,0 +1,24 @@ +package guardianpaymentmethod + +import ( + "skillspark/internal/stripeClient" + "skillspark/internal/storage" +) + +type Handler struct { + GuardianRepository storage.GuardianRepository + GuardianPaymentMethodRepository storage.GuardianPaymentMethodRepository + StripeClient stripeClient.StripeClientInterface +} + +func NewHandler( + guardianRepo storage.GuardianRepository, + paymentMethodRepo storage.GuardianPaymentMethodRepository, + sc stripeClient.StripeClientInterface, +) *Handler { + return &Handler{ + GuardianRepository: guardianRepo, + GuardianPaymentMethodRepository: paymentMethodRepo, + StripeClient: sc, + } +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian-payment-method/handler_test.go b/backend/internal/service/handler/guardian-payment-method/handler_test.go new file mode 100644 index 00000000..33a392c4 --- /dev/null +++ b/backend/internal/service/handler/guardian-payment-method/handler_test.go @@ -0,0 +1,360 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" + repomocks "skillspark/internal/storage/repo-mocks" + stripemocks "skillspark/internal/stripeClient/mocks" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestHandler_GetPaymentMethodsByGuardianID(t *testing.T) { + tests := []struct { + name string + guardianID string + mockSetup func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) + wantErr bool + wantCount int + }{ + { + name: "successfully retrieves payment methods", + guardianID: "88888888-8888-8888-8888-888888888888", + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + stripeCustomerID := "cus_test123" + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripeCustomerID: &stripeCustomerID, + }, nil) + + cardBrand := "visa" + cardLast4 := "4242" + pmr.On("GetPaymentMethodsByGuardianID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return([]models.GuardianPaymentMethod{ + { + ID: uuid.New(), + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripePaymentMethodID: "pm_test123", + CardBrand: &cardBrand, + CardLast4: &cardLast4, + IsDefault: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }, nil) + }, + wantErr: false, + wantCount: 1, + }, + { + name: "guardian has no stripe customer - returns empty", + guardianID: "88888888-8888-8888-8888-888888888889", + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888889")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888889"), + StripeCustomerID: nil, + }, nil) + }, + wantErr: false, + wantCount: 0, + }, + { + name: "guardian not found", + guardianID: "00000000-0000-0000-0000-000000000000", + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("00000000-0000-0000-0000-000000000000")). + Return(nil, &errs.HTTPError{ + Code: errs.NotFound("Guardian", "id", "00000000-0000-0000-0000-000000000000").Code, + Message: "Not found", + }) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockGuardianRepo, mockPaymentMethodRepo) + + handler := NewHandler(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + ctx := context.Background() + + input := &models.GetGuardianPaymentMethodsByGuardianIDInput{ + GuardianID: uuid.MustParse(tt.guardianID), + } + output, err := handler.GetPaymentMethodsByGuardianID(ctx, input) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, output) + } else { + assert.NoError(t, err) + require.NotNil(t, output) + assert.Equal(t, tt.wantCount, len(output.Body)) + } + + mockGuardianRepo.AssertExpectations(t) + mockPaymentMethodRepo.AssertExpectations(t) + }) + } +} + +func TestHandler_CreateGuardianPaymentMethod(t *testing.T) { + tests := []struct { + name string + input *models.CreateGuardianPaymentMethodInput + mockSetup func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) + wantErr bool + }{ + { + name: "successfully creates payment method", + input: func() *models.CreateGuardianPaymentMethodInput { + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = uuid.MustParse("88888888-8888-8888-8888-888888888888") + input.Body.IsDefault = true + return input + }(), + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + stripeCustomerID := "cus_test123" + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripeCustomerID: &stripeCustomerID, + }, nil) + + pmr.On("CreateGuardianPaymentMethod", mock.Anything, mock.AnythingOfType("*models.CreateGuardianPaymentMethodInput")). + Return(&models.GuardianPaymentMethod{ + ID: uuid.New(), + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripePaymentMethodID: "pm_test123", + IsDefault: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil) + }, + wantErr: false, + }, + { + name: "fails when guardian has no stripe customer", + input: func() *models.CreateGuardianPaymentMethodInput { + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = uuid.MustParse("88888888-8888-8888-8888-888888888889") + input.Body.IsDefault = false + return input + }(), + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888889")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888889"), + StripeCustomerID: nil, + }, nil) + }, + wantErr: true, + }, + { + name: "fails when guardian not found", + input: func() *models.CreateGuardianPaymentMethodInput { + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = uuid.MustParse("00000000-0000-0000-0000-000000000000") + input.Body.IsDefault = false + return input + }(), + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("00000000-0000-0000-0000-000000000000")). + Return(nil, &errs.HTTPError{ + Code: errs.NotFound("Guardian", "id", "00000000-0000-0000-0000-000000000000").Code, + Message: "Not found", + }) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockGuardianRepo, mockPaymentMethodRepo) + + handler := NewHandler(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + ctx := context.Background() + + paymentMethod, err := handler.CreateGuardianPaymentMethod(ctx, tt.input) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, paymentMethod) + } else { + assert.NoError(t, err) + assert.NotNil(t, paymentMethod) + } + + mockGuardianRepo.AssertExpectations(t) + mockPaymentMethodRepo.AssertExpectations(t) + }) + } +} + +func TestHandler_DeleteGuardianPaymentMethod(t *testing.T) { + tests := []struct { + name string + pmID string + mockSetup func(*repomocks.MockGuardianPaymentMethodRepository, *stripemocks.MockStripeClient) + wantErr bool + }{ + { + name: "successfully deletes payment method", + pmID: "11111111-1111-1111-1111-111111111111", + mockSetup: func(pmr *repomocks.MockGuardianPaymentMethodRepository, sc *stripemocks.MockStripeClient) { + pmr.On("DeleteGuardianPaymentMethod", mock.Anything, uuid.MustParse("11111111-1111-1111-1111-111111111111")). + Return(&models.GuardianPaymentMethod{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + StripePaymentMethodID: "pm_test123", + IsDefault: false, + }, nil) + + sc.On("DetachPaymentMethod", mock.Anything, "pm_test123").Return(nil) + }, + wantErr: false, + }, + { + name: "payment method not found", + pmID: "00000000-0000-0000-0000-000000000000", + mockSetup: func(pmr *repomocks.MockGuardianPaymentMethodRepository, sc *stripemocks.MockStripeClient) { + pmr.On("DeleteGuardianPaymentMethod", mock.Anything, uuid.MustParse("00000000-0000-0000-0000-000000000000")). + Return(nil, &errs.HTTPError{ + Code: errs.NotFound("Guardian Payment Method", "id", "00000000-0000-0000-0000-000000000000").Code, + Message: "Not found", + }) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockPaymentMethodRepo, mockStripeClient) + + handler := NewHandler(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + ctx := context.Background() + + input := &models.DeleteGuardianPaymentMethodInput{ + ID: uuid.MustParse(tt.pmID), + } + output, err := handler.DeleteGuardianPaymentMethod(ctx, input) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, output) + } else { + assert.NoError(t, err) + assert.NotNil(t, output) + assert.Equal(t, "Payment method deleted successfully", output.Body.Message) + } + + mockPaymentMethodRepo.AssertExpectations(t) + mockStripeClient.AssertExpectations(t) + }) + } +} + +func TestHandler_UpdateGuardianPaymentMethod(t *testing.T) { + tests := []struct { + name string + input *models.SetDefaultPaymentMethodInput + mockSetup func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) + wantErr bool + }{ + { + name: "successfully sets payment method as default", + input: &models.SetDefaultPaymentMethodInput{ + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + PaymentMethodID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }, + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + stripeCustomerID := "cus_test123" + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripeCustomerID: &stripeCustomerID, + }, nil) + + pmr.On("UpdateGuardianPaymentMethod", mock.Anything, uuid.MustParse("11111111-1111-1111-1111-111111111111"), true). + Return(&models.GuardianPaymentMethod{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + IsDefault: true, + }, nil) + }, + wantErr: false, + }, + { + name: "fails when guardian has no stripe customer", + input: &models.SetDefaultPaymentMethodInput{ + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888889"), + PaymentMethodID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + }, + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888889")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888889"), + StripeCustomerID: nil, + }, nil) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockGuardianRepo, mockPaymentMethodRepo) + + handler := NewHandler(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + ctx := context.Background() + + output, err := handler.SetDefaultGuardianPaymentMethod(ctx, tt.input) + + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, output) + } else { + assert.NoError(t, err) + require.NotNil(t, output) + assert.True(t, output.Body.IsDefault) + } + + mockGuardianRepo.AssertExpectations(t) + mockPaymentMethodRepo.AssertExpectations(t) + }) + } +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian-payment-method/setDefaultGuardianPaymentMethod.go b/backend/internal/service/handler/guardian-payment-method/setDefaultGuardianPaymentMethod.go new file mode 100644 index 00000000..e8fe8c74 --- /dev/null +++ b/backend/internal/service/handler/guardian-payment-method/setDefaultGuardianPaymentMethod.go @@ -0,0 +1,31 @@ +package guardianpaymentmethod + +import ( + "context" + "errors" + "skillspark/internal/models" +) + +func (h *Handler) SetDefaultGuardianPaymentMethod( + ctx context.Context, + input *models.SetDefaultPaymentMethodInput, +) (*models.SetDefaultPaymentMethodOutput, error) { + + guardian, err := h.GuardianRepository.GetGuardianByID(ctx, input.GuardianID) + if err != nil { + return nil, err + } + + if guardian.StripeCustomerID == nil || *guardian.StripeCustomerID == "" { + return nil, errors.New("guardian must have stripe customer ID") + } + + updatedPaymentMethod, err := h.GuardianPaymentMethodRepository.UpdateGuardianPaymentMethod(ctx, input.PaymentMethodID, true) + if err != nil { + return nil, err + } + + return &models.SetDefaultPaymentMethodOutput{ + Body: *updatedPaymentMethod, + }, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian/handler.go b/backend/internal/service/handler/guardian/handler.go index 3f1c5e76..6fbc4d05 100644 --- a/backend/internal/service/handler/guardian/handler.go +++ b/backend/internal/service/handler/guardian/handler.go @@ -1,13 +1,19 @@ package guardian -import "skillspark/internal/storage" +import ( + "skillspark/internal/storage" + "skillspark/internal/stripeClient" +) type Handler struct { GuardianRepository storage.GuardianRepository + StripeClient stripeClient.StripeClientInterface } -func NewHandler(guardianRepository storage.GuardianRepository) *Handler { +func NewHandler(guardianRepository storage.GuardianRepository, sc stripeClient.StripeClientInterface) *Handler { return &Handler{ GuardianRepository: guardianRepository, + StripeClient: sc, + } } diff --git a/backend/internal/service/handler/guardian/handler_test.go b/backend/internal/service/handler/guardian/handler_test.go index 7a15532d..0dbfee06 100644 --- a/backend/internal/service/handler/guardian/handler_test.go +++ b/backend/internal/service/handler/guardian/handler_test.go @@ -5,6 +5,7 @@ import ( "skillspark/internal/errs" "skillspark/internal/models" repomocks "skillspark/internal/storage/repo-mocks" + stripemocks "skillspark/internal/stripeClient/mocks" "testing" "time" @@ -60,14 +61,15 @@ func TestHandler_GetGuardianById(t *testing.T) { } for _, tt := range tests { - tt := tt // capture range variable for parallel + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() mockRepo := new(repomocks.MockGuardianRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, mockStripeClient) ctx := context.Background() input := &models.GetGuardianByIDInput{ID: uuid.MustParse(tt.id)} @@ -137,14 +139,15 @@ func TestHandler_CreateGuardian(t *testing.T) { } for _, tt := range tests { - tt := tt // capture range variable for parallel + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() mockRepo := new(repomocks.MockGuardianRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, mockStripeClient) ctx := context.Background() guardian, err := handler.CreateGuardian(ctx, tt.input) @@ -220,14 +223,15 @@ func TestHandler_UpdateGuardian(t *testing.T) { } for _, tt := range tests { - tt := tt // capture range variable for parallel + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() mockRepo := new(repomocks.MockGuardianRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, mockStripeClient) ctx := context.Background() guardian, err := handler.UpdateGuardian(ctx, tt.input) @@ -281,14 +285,15 @@ func TestHandler_GetGuardianByChildId(t *testing.T) { } for _, tt := range tests { - tt := tt // capture range variable for parallel + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() mockRepo := new(repomocks.MockGuardianRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, mockStripeClient) ctx := context.Background() input := &models.GetGuardianByChildIDInput{ChildID: uuid.MustParse(tt.childID)} @@ -316,7 +321,7 @@ func TestHandler_DeleteGuardian(t *testing.T) { wantErr bool }{ { - name: "successful delete guardian - Sarah Johnson", // assume guardian has no children + name: "successful delete guardian - Sarah Johnson", id: "11111111-1111-1111-1111-111111111111", mockSetup: func(m *repomocks.MockGuardianRepository) { m.On("DeleteGuardian", mock.Anything, uuid.MustParse("11111111-1111-1111-1111-111111111111")).Return(&models.Guardian{ @@ -343,14 +348,15 @@ func TestHandler_DeleteGuardian(t *testing.T) { } for _, tt := range tests { - tt := tt // capture range variable for parallel + tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() mockRepo := new(repomocks.MockGuardianRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - handler := NewHandler(mockRepo) + handler := NewHandler(mockRepo, mockStripeClient) ctx := context.Background() input := &models.DeleteGuardianInput{ID: uuid.MustParse(tt.id)} @@ -368,4 +374,4 @@ func TestHandler_DeleteGuardian(t *testing.T) { mockRepo.AssertExpectations(t) }) } -} +} \ No newline at end of file diff --git a/backend/internal/service/handler/guardian/setCustomerId.go b/backend/internal/service/handler/guardian/setCustomerId.go new file mode 100644 index 00000000..a04898c7 --- /dev/null +++ b/backend/internal/service/handler/guardian/setCustomerId.go @@ -0,0 +1,36 @@ +package guardian + +import ( + "context" + "errors" + "skillspark/internal/models" +) + +func (h *Handler) CreateStripeCustomer( + ctx context.Context, + input *models.CreateStripeCustomerInput, +) (*models.CreateStripeCustomerOutput, error) { + + guardian, err := h.GuardianRepository.GetGuardianByID(ctx, input.GuardianID) + if err != nil { + return nil, err + } + + if guardian.StripeCustomerID != nil && *guardian.StripeCustomerID != "" { + return nil, errors.New("guardian already has stripe customer") + } + + customer, err := h.StripeClient.CreateCustomer(ctx, guardian.Email, guardian.Name) + if err != nil { + return nil, err + } + + updatedGuardian, err := h.GuardianRepository.SetStripeCustomerID(ctx, guardian.ID, customer.ID) + if err != nil { + return nil, err + } + + return &models.CreateStripeCustomerOutput{ + Body: *updatedGuardian, + }, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/payment/createCustomerSetupIntent.go b/backend/internal/service/handler/payment/createCustomerSetupIntent.go new file mode 100644 index 00000000..927c67d8 --- /dev/null +++ b/backend/internal/service/handler/payment/createCustomerSetupIntent.go @@ -0,0 +1,32 @@ +package payment + +import ( + "context" + "errors" + "skillspark/internal/models" +) + +func (h *Handler) CreateSetupIntent( + ctx context.Context, + input *models.CreateSetupIntentInput, +) (*models.CreateSetupIntentOutput, error) { + + guardian, err := h.GuardianRepository.GetGuardianByID(ctx, input.GuardianID) + if err != nil { + return nil, err + } + + if guardian.StripeCustomerID == nil || *guardian.StripeCustomerID == "" { + return nil, errors.New("guardian must have stripe customer ID") + } + + clientSecret, err := h.StripeClient.CreateSetupIntent(ctx, *guardian.StripeCustomerID) + if err != nil { + return nil, err + } + + output := &models.CreateSetupIntentOutput{} + output.Body.ClientSecret = clientSecret + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/payment/createStripeOnboardingLink.go b/backend/internal/service/handler/payment/createStripeOnboardingLink.go new file mode 100644 index 00000000..7d829885 --- /dev/null +++ b/backend/internal/service/handler/payment/createStripeOnboardingLink.go @@ -0,0 +1,32 @@ +package payment + +import ( + "context" + "errors" + "skillspark/internal/models" +) + +func (h *Handler) CreateOrgLoginLink( + ctx context.Context, + input *models.CreateOrgLoginLinkInput, +) (*models.CreateOrgLoginLinkOutput, error) { + + org, err := h.OrganizationRepository.GetOrganizationByID(ctx, input.OrganizationID) + if err != nil { + return nil, err + } + + if org.StripeAccountID == nil || *org.StripeAccountID == "" { + return nil, errors.New("organization must have stripe account") + } + + loginURL, err := h.StripeClient.CreateLoginLink(ctx, *org.StripeAccountID) + if err != nil { + return nil, err + } + + output := &models.CreateOrgLoginLinkOutput{} + output.Body.LoginURL = loginURL + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/payment/createStripeOrganizationAccount.go b/backend/internal/service/handler/payment/createStripeOrganizationAccount.go new file mode 100644 index 00000000..dc134294 --- /dev/null +++ b/backend/internal/service/handler/payment/createStripeOrganizationAccount.go @@ -0,0 +1,45 @@ +package payment + +import ( + "context" + "skillspark/internal/models" +) + +func (h *Handler) CreateOrgStripeAccount( + ctx context.Context, + input *models.CreateOrgStripeAccountInput, +) (*models.Organization, error) { + + org, orgErr := h.OrganizationRepository.GetOrganizationByID(ctx, input.Body.OrganizationID) + + if orgErr != nil { + return nil, orgErr + } + + manager, manErr := h.ManagerRepository.GetManagerByOrgID(ctx, input.Body.OrganizationID) + + if manErr != nil { + return nil, manErr + } + + location, locErr := h.LocationRepository.GetLocationByOrganizationID(ctx, input.Body.OrganizationID) + + if locErr != nil { + return nil, locErr + } + + stripeAccount, err := h.StripeClient.CreateOrganizationAccount(ctx, org.Name, manager.Email, location.Country) + + if (err != nil) { + return nil, err + } + + updatedOrg, err := h.OrganizationRepository.SetStripeAccountID(ctx, input.Body.OrganizationID, stripeAccount.Body.Account.ID) + + if (err != nil) { + return nil, err + } + + return updatedOrg, nil + +} \ No newline at end of file diff --git a/backend/internal/service/handler/payment/handleStripeWebhook.go b/backend/internal/service/handler/payment/handleStripeWebhook.go new file mode 100644 index 00000000..6c1763be --- /dev/null +++ b/backend/internal/service/handler/payment/handleStripeWebhook.go @@ -0,0 +1 @@ +package payment \ No newline at end of file diff --git a/backend/internal/service/handler/payment/handler.go b/backend/internal/service/handler/payment/handler.go new file mode 100644 index 00000000..9c60b40f --- /dev/null +++ b/backend/internal/service/handler/payment/handler.go @@ -0,0 +1,39 @@ +package payment + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage" + "skillspark/internal/stripeClient" +) + +type Handler struct { + OrganizationRepository storage.OrganizationRepository + ManagerRepository storage.ManagerRepository + RegistrationRepository storage.RegistrationRepository + LocationRepository storage.LocationRepository + GuardianRepository storage.GuardianRepository + StripeClient stripeClient.StripeClientInterface +} + +func (h *Handler) CreateAccountOnboardingLink(ctx context.Context, input *models.CreateStripeOnboardingLinkInput) (*models.CreateStripeOnboardingLinkOutput, error) { + panic("unimplemented") +} + +func NewHandler( + orgRepo storage.OrganizationRepository, + managerRepo storage.ManagerRepository, + registrationRepo storage.RegistrationRepository, + locRepo storage.LocationRepository, + guardianRepo storage.GuardianRepository, + sc stripeClient.StripeClientInterface, +) *Handler { + return &Handler{ + OrganizationRepository: orgRepo, + ManagerRepository: managerRepo, + RegistrationRepository: registrationRepo, + LocationRepository: locRepo, + GuardianRepository: guardianRepo, + StripeClient: sc, + } +} diff --git a/backend/internal/service/handler/registration/cancelRegistration.go b/backend/internal/service/handler/registration/cancelRegistration.go new file mode 100644 index 00000000..0cd0731b --- /dev/null +++ b/backend/internal/service/handler/registration/cancelRegistration.go @@ -0,0 +1,59 @@ +package registration + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" +) + +func (h *Handler) CancelRegistration(ctx context.Context, input *models.CancelRegistrationInput) (*models.CancelRegistrationOutput, error) { + getInput := &models.GetRegistrationByIDInput{ + ID: input.ID, + } + registration, err := h.RegistrationRepository.GetRegistrationByID(ctx, getInput) + if err != nil { + return nil, err + } + + if registration.Body.Status == models.RegistrationStatusCancelled { + return nil, errs.BadRequest("Registration is already cancelled") + } + + var refundStatus string + switch registration.Body.PaymentIntentStatus { + case "succeeded": + refundInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: registration.Body.StripePaymentIntentID, + StripeAccountID: registration.Body.OrgStripeAccountID, + } + + refundOutput, err := h.StripeClient.CancelPaymentIntent(ctx, refundInput) + if err != nil { + return nil, errs.InternalServerError("Failed to refund payment: ", err.Error()) + } + refundStatus = refundOutput.Body.Status + case "requires_capture": + cancelInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: registration.Body.StripePaymentIntentID, + StripeAccountID: registration.Body.OrgStripeAccountID, + } + + _, err := h.StripeClient.CancelPaymentIntent(ctx, cancelInput) + if err != nil { + return nil, errs.InternalServerError("Failed to cancel payment intent: ", err.Error()) + } + refundStatus = "cancelled" + default: + refundStatus = "no_refund_needed" + } + + cancelledRegistration, err := h.RegistrationRepository.CancelRegistration(ctx, input) + if err != nil { + return nil, err + } + + cancelledRegistration.Body.Message = "Registration cancelled successfully" + cancelledRegistration.Body.RefundStatus = refundStatus + + return cancelledRegistration, nil +} \ No newline at end of file diff --git a/backend/internal/service/handler/registration/createRegistration.go b/backend/internal/service/handler/registration/createRegistration.go index d93f0875..963e5fa8 100644 --- a/backend/internal/service/handler/registration/createRegistration.go +++ b/backend/internal/service/handler/registration/createRegistration.go @@ -2,6 +2,7 @@ package registration import ( "context" + "errors" "skillspark/internal/errs" "skillspark/internal/models" ) @@ -20,7 +21,44 @@ func (h *Handler) CreateRegistration(ctx context.Context, input *models.CreateRe return nil, errs.BadRequest("Invalid guardian_id: guardian does not exist") } - registration, err := h.RegistrationRepository.CreateRegistration(ctx, input) + eventOccurrence, _ := h.EventOccurrenceRepository.GetEventOccurrenceByID(ctx, input.Body.EventOccurrenceID) + + guardian, _ := h.GuardianRepository.GetGuardianByID(ctx, input.Body.GuardianID) + + org, _ := h.OrganizationRepository.GetOrganizationByID(ctx, eventOccurrence.Event.OrganizationID) + + + piInput := models.CreatePaymentIntentInput{} + piInput.Body.Amount = int64(eventOccurrence.Price) + piInput.Body.Currency = input.Body.Currency + piInput.Body.GuardianStripeID = *guardian.StripeCustomerID + piInput.Body.OrgStripeID = *org.StripeAccountID + piInput.Body.PaymentMethodID = &input.Body.PaymentMethodID + piInput.Body.EventDate = eventOccurrence.StartTime + + + paymentIntent, err := h.StripeClient.CreatePaymentIntent(ctx, &piInput) + if err != nil { + return nil, errors.New("failed to create payment intent") + } + + completeRegistration := &models.CreateRegistrationWithPaymentData{ + ChildID: input.Body.ChildID, + GuardianID: input.Body.GuardianID, + EventOccurrenceID: input.Body.EventOccurrenceID, + Status: input.Body.Status, + StripePaymentIntentID: paymentIntent.Body.PaymentIntentID, + StripeCustomerID: *guardian.StripeCustomerID, + OrgStripeAccountID: *org.StripeAccountID, + StripePaymentMethodID: input.Body.PaymentMethodID, + TotalAmount: paymentIntent.Body.TotalAmount, + ProviderAmount: paymentIntent.Body.ProviderAmount, + PlatformFeeAmount: paymentIntent.Body.PlatformFeeAmount, + Currency: paymentIntent.Body.Currency, + PaymentIntentStatus: paymentIntent.Body.Status, + } + + registration, err := h.RegistrationRepository.CreateRegistration(ctx, completeRegistration) if err != nil { return nil, err } diff --git a/backend/internal/service/handler/registration/handler.go b/backend/internal/service/handler/registration/handler.go index 03fe10cd..8c9311a9 100644 --- a/backend/internal/service/handler/registration/handler.go +++ b/backend/internal/service/handler/registration/handler.go @@ -1,19 +1,26 @@ package registration -import "skillspark/internal/storage" +import ( + "skillspark/internal/storage" + "skillspark/internal/stripeClient" +) type Handler struct { RegistrationRepository storage.RegistrationRepository EventOccurrenceRepository storage.EventOccurrenceRepository GuardianRepository storage.GuardianRepository ChildRepository storage.ChildRepository + OrganizationRepository storage.OrganizationRepository + StripeClient stripeClient.StripeClientInterface } -func NewHandler(registrationRepo storage.RegistrationRepository, childRepo storage.ChildRepository, guardianRepo storage.GuardianRepository, eventOccurrenceRepo storage.EventOccurrenceRepository) *Handler { +func NewHandler(registrationRepo storage.RegistrationRepository, childRepo storage.ChildRepository, guardianRepo storage.GuardianRepository, eventOccurrenceRepo storage.EventOccurrenceRepository, organizationRepo storage.OrganizationRepository, sc stripeClient.StripeClientInterface) *Handler { return &Handler{ RegistrationRepository: registrationRepo, ChildRepository: childRepo, GuardianRepository: guardianRepo, EventOccurrenceRepository: eventOccurrenceRepo, + OrganizationRepository: organizationRepo, + StripeClient: sc, } } diff --git a/backend/internal/service/handler/registration/handler_test.go b/backend/internal/service/handler/registration/handler_test.go index ffba66ca..8b88f3c4 100644 --- a/backend/internal/service/handler/registration/handler_test.go +++ b/backend/internal/service/handler/registration/handler_test.go @@ -5,6 +5,7 @@ import ( "skillspark/internal/errs" "skillspark/internal/models" repomocks "skillspark/internal/storage/repo-mocks" + stripemocks "skillspark/internal/stripeClient/mocks" "testing" "time" @@ -26,15 +27,26 @@ func TestHandler_GetRegistrationByID(t *testing.T) { mockSetup: func(m *repomocks.MockRegistrationRepository) { m.On("GetRegistrationByID", mock.Anything, mock.AnythingOfType("*models.GetRegistrationByIDInput")).Return(&models.GetRegistrationByIDOutput{ Body: models.Registration{ - ID: uuid.MustParse("80000000-0000-0000-0000-000000000001"), - ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001"), - GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), - Status: models.RegistrationStatusRegistered, - EventName: "STEM Club", - OccurrenceStartTime: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.MustParse("80000000-0000-0000-0000-000000000001"), + ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001"), + GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), + Status: models.RegistrationStatusRegistered, + EventName: "STEM Club", + OccurrenceStartTime: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + CancelledAt: nil, + PaidAt: nil, }, }, nil) }, @@ -62,9 +74,11 @@ func TestHandler_GetRegistrationByID(t *testing.T) { mockChildRepo := new(repomocks.MockChildRepository) mockGuardianRepo := new(repomocks.MockGuardianRepository) mockEORepo := new(repomocks.MockEventOccurrenceRepository) + mockOrgRepo := new(repomocks.MockOrganizationRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRegRepo) - handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) ctx := context.Background() input := &models.GetRegistrationByIDInput{ID: uuid.MustParse(tt.id)} @@ -77,6 +91,8 @@ func TestHandler_GetRegistrationByID(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, registration) assert.Equal(t, tt.id, registration.Body.ID.String()) + assert.NotEmpty(t, registration.Body.StripePaymentIntentID) + assert.NotEmpty(t, registration.Body.Currency) } mockRegRepo.AssertExpectations(t) @@ -98,8 +114,22 @@ func TestHandler_GetRegistrationsByChildID(t *testing.T) { mockSetup: func(m *repomocks.MockRegistrationRepository) { output := &models.GetRegistrationsByChildIDOutput{} output.Body.Registrations = []models.Registration{ - {ID: uuid.New(), ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001")}, - {ID: uuid.New(), ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001")}, + { + ID: uuid.New(), + ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001"), + StripePaymentIntentID: "pi_test_1", + StripeCustomerID: "cus_test_123", + TotalAmount: 10000, + Currency: "usd", + }, + { + ID: uuid.New(), + ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001"), + StripePaymentIntentID: "pi_test_2", + StripeCustomerID: "cus_test_123", + TotalAmount: 10000, + Currency: "usd", + }, } m.On("GetRegistrationsByChildID", mock.Anything, mock.AnythingOfType("*models.GetRegistrationsByChildIDInput")).Return(output, nil) }, @@ -127,9 +157,11 @@ func TestHandler_GetRegistrationsByChildID(t *testing.T) { mockChildRepo := new(repomocks.MockChildRepository) mockGuardianRepo := new(repomocks.MockGuardianRepository) mockEORepo := new(repomocks.MockEventOccurrenceRepository) + mockOrgRepo := new(repomocks.MockOrganizationRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRegRepo) - handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) ctx := context.Background() input := &models.GetRegistrationsByChildIDInput{ChildID: uuid.MustParse(tt.childID)} @@ -163,9 +195,24 @@ func TestHandler_GetRegistrationsByGuardianID(t *testing.T) { mockSetup: func(m *repomocks.MockRegistrationRepository) { output := &models.GetRegistrationsByGuardianIDOutput{} output.Body.Registrations = []models.Registration{ - {ID: uuid.New(), GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111")}, - {ID: uuid.New(), GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111")}, - {ID: uuid.New(), GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111")}, + { + ID: uuid.New(), + GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + StripePaymentIntentID: "pi_test_1", + Currency: "usd", + }, + { + ID: uuid.New(), + GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + StripePaymentIntentID: "pi_test_2", + Currency: "usd", + }, + { + ID: uuid.New(), + GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + StripePaymentIntentID: "pi_test_3", + Currency: "usd", + }, } m.On("GetRegistrationsByGuardianID", mock.Anything, mock.AnythingOfType("*models.GetRegistrationsByGuardianIDInput")).Return(output, nil) }, @@ -193,9 +240,11 @@ func TestHandler_GetRegistrationsByGuardianID(t *testing.T) { mockChildRepo := new(repomocks.MockChildRepository) mockGuardianRepo := new(repomocks.MockGuardianRepository) mockEORepo := new(repomocks.MockEventOccurrenceRepository) + mockOrgRepo := new(repomocks.MockOrganizationRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRegRepo) - handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) ctx := context.Background() input := &models.GetRegistrationsByGuardianIDInput{GuardianID: uuid.MustParse(tt.guardianID)} @@ -229,9 +278,24 @@ func TestHandler_GetRegistrationsByEventOccurrenceID(t *testing.T) { mockSetup: func(m *repomocks.MockRegistrationRepository) { output := &models.GetRegistrationsByEventOccurrenceIDOutput{} output.Body.Registrations = []models.Registration{ - {ID: uuid.New(), EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001")}, - {ID: uuid.New(), EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001")}, - {ID: uuid.New(), EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001")}, + { + ID: uuid.New(), + EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), + StripePaymentIntentID: "pi_test_1", + Currency: "usd", + }, + { + ID: uuid.New(), + EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), + StripePaymentIntentID: "pi_test_2", + Currency: "usd", + }, + { + ID: uuid.New(), + EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), + StripePaymentIntentID: "pi_test_3", + Currency: "usd", + }, } m.On("GetRegistrationsByEventOccurrenceID", mock.Anything, mock.AnythingOfType("*models.GetRegistrationsByEventOccurrenceIDInput")).Return(output, nil) }, @@ -260,9 +324,11 @@ func TestHandler_GetRegistrationsByEventOccurrenceID(t *testing.T) { mockChildRepo := new(repomocks.MockChildRepository) mockGuardianRepo := new(repomocks.MockGuardianRepository) mockEORepo := new(repomocks.MockEventOccurrenceRepository) + mockOrgRepo := new(repomocks.MockOrganizationRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRegRepo) - handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) ctx := context.Background() input := &models.GetRegistrationsByEventOccurrenceIDInput{EventOccurrenceID: uuid.MustParse(tt.eventOccurrenceID)} @@ -286,6 +352,7 @@ func TestHandler_CreateRegistration(t *testing.T) { childID := uuid.MustParse("30000000-0000-0000-0000-000000000001") guardianID := uuid.MustParse("11111111-1111-1111-1111-111111111111") eventOccurrenceID := uuid.MustParse("70000000-0000-0000-0000-000000000001") + organizationID := uuid.MustParse("10000000-0000-0000-0000-000000000001") invalidChildID := uuid.New() invalidGuardianID := uuid.New() invalidEventOccurrenceID := uuid.New() @@ -293,7 +360,7 @@ func TestHandler_CreateRegistration(t *testing.T) { tests := []struct { name string input *models.CreateRegistrationInput - mockSetup func(*repomocks.MockRegistrationRepository, *repomocks.MockChildRepository, *repomocks.MockGuardianRepository, *repomocks.MockEventOccurrenceRepository) + mockSetup func(*repomocks.MockRegistrationRepository, *repomocks.MockChildRepository, *repomocks.MockGuardianRepository, *repomocks.MockEventOccurrenceRepository, *repomocks.MockOrganizationRepository, *stripemocks.MockStripeClient) wantErr bool }{ { @@ -304,29 +371,79 @@ func TestHandler_CreateRegistration(t *testing.T) { i.Body.GuardianID = guardianID i.Body.EventOccurrenceID = eventOccurrenceID i.Body.Status = models.RegistrationStatusRegistered + i.Body.Currency = "usd" + i.Body.PaymentMethodID = "pm_test_123" return i }(), - mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository) { + mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository, orgRepo *repomocks.MockOrganizationRepository, sc *stripemocks.MockStripeClient) { + stripeAccountID := "acct_test_123" + stripeCustomerID := "cus_test_123" + eoRepo.On("GetEventOccurrenceByID", mock.Anything, eventOccurrenceID).Return(&models.EventOccurrence{ - ID: eventOccurrenceID, + ID: eventOccurrenceID, + Price: 10000, + StartTime: time.Now().Add(25 * time.Hour), + Event: models.Event{ + ID: uuid.New(), + OrganizationID: organizationID, + Title: "STEM Club", + }, }, nil) + childRepo.On("GetChildByID", mock.Anything, childID).Return(&models.Child{ - ID: childID, + ID: childID, + GuardianID: guardianID, }, nil) + guardianRepo.On("GetGuardianByID", mock.Anything, guardianID).Return(&models.Guardian{ - ID: guardianID, + ID: guardianID, + StripeCustomerID: &stripeCustomerID, + }, nil) + + orgRepo.On("GetOrganizationByID", mock.Anything, organizationID).Return(&models.Organization{ + ID: organizationID, + StripeAccountID: &stripeAccountID, }, nil) - regRepo.On("CreateRegistration", mock.Anything, mock.AnythingOfType("*models.CreateRegistrationInput")).Return(&models.CreateRegistrationOutput{ + + sc.On("CreatePaymentIntent", mock.Anything, mock.AnythingOfType("*models.CreatePaymentIntentInput")).Return(&models.CreatePaymentIntentOutput{ + Body: struct { + PaymentIntentID string `json:"payment_intent_id" doc:"Stripe payment intent ID"` + ClientSecret string `json:"client_secret" doc:"Client secret for frontend to confirm payment"` + Status string `json:"status" doc:"Payment intent status"` + TotalAmount int `json:"total_amount" doc:"Total amount in cents"` + ProviderAmount int `json:"provider_amount" doc:"Amount provider receives in cents"` + PlatformFeeAmount int `json:"platform_fee_amount" doc:"Platform fee in cents"` + Currency string `json:"currency" doc:"Currency code"` + }{ + PaymentIntentID: "pi_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + Status: "requires_capture", + }, + }, nil) + + regRepo.On("CreateRegistration", mock.Anything, mock.AnythingOfType("*models.CreateRegistrationWithPaymentData")).Return(&models.CreateRegistrationOutput{ Body: models.Registration{ - ID: uuid.New(), - ChildID: childID, - GuardianID: guardianID, - EventOccurrenceID: eventOccurrenceID, - Status: models.RegistrationStatusRegistered, - EventName: "STEM Club", - OccurrenceStartTime: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New(), + ChildID: childID, + GuardianID: guardianID, + EventOccurrenceID: eventOccurrenceID, + Status: models.RegistrationStatusRegistered, + EventName: "STEM Club", + OccurrenceStartTime: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", }, }, nil) }, @@ -342,7 +459,7 @@ func TestHandler_CreateRegistration(t *testing.T) { i.Body.Status = models.RegistrationStatusRegistered return i }(), - mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository) { + mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository, orgRepo *repomocks.MockOrganizationRepository, sc *stripemocks.MockStripeClient) { eoRepo.On("GetEventOccurrenceByID", mock.Anything, invalidEventOccurrenceID). Return(nil, &errs.HTTPError{ Code: errs.NotFound("EventOccurrence", "id", invalidEventOccurrenceID.String()).Code, @@ -361,7 +478,7 @@ func TestHandler_CreateRegistration(t *testing.T) { i.Body.Status = models.RegistrationStatusRegistered return i }(), - mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository) { + mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository, orgRepo *repomocks.MockOrganizationRepository, sc *stripemocks.MockStripeClient) { eoRepo.On("GetEventOccurrenceByID", mock.Anything, eventOccurrenceID).Return(&models.EventOccurrence{ ID: eventOccurrenceID, }, nil) @@ -383,7 +500,7 @@ func TestHandler_CreateRegistration(t *testing.T) { i.Body.Status = models.RegistrationStatusRegistered return i }(), - mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository) { + mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eoRepo *repomocks.MockEventOccurrenceRepository, orgRepo *repomocks.MockOrganizationRepository, sc *stripemocks.MockStripeClient) { eoRepo.On("GetEventOccurrenceByID", mock.Anything, eventOccurrenceID).Return(&models.EventOccurrence{ ID: eventOccurrenceID, }, nil) @@ -408,9 +525,11 @@ func TestHandler_CreateRegistration(t *testing.T) { mockChildRepo := new(repomocks.MockChildRepository) mockGuardianRepo := new(repomocks.MockGuardianRepository) mockEORepo := new(repomocks.MockEventOccurrenceRepository) - tt.mockSetup(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + mockOrgRepo := new(repomocks.MockOrganizationRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) - handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) ctx := context.Background() registration, err := handler.CreateRegistration(ctx, tt.input) @@ -428,14 +547,15 @@ func TestHandler_CreateRegistration(t *testing.T) { mockChildRepo.AssertExpectations(t) mockGuardianRepo.AssertExpectations(t) mockEORepo.AssertExpectations(t) + mockOrgRepo.AssertExpectations(t) }) } } func TestHandler_UpdateRegistration(t *testing.T) { existingID := uuid.MustParse("80000000-0000-0000-0000-000000000001") - newStatus := models.RegistrationStatusCancelled - newChildID := uuid.New() + newChildID := uuid.MustParse("30000000-0000-0000-0000-000000000002") + invalidChildID := uuid.New() tests := []struct { name string @@ -444,24 +564,37 @@ func TestHandler_UpdateRegistration(t *testing.T) { wantErr bool }{ { - name: "successful update status only", + name: "successful update child", input: func() *models.UpdateRegistrationInput { i := &models.UpdateRegistrationInput{ID: existingID} - i.Body.Status = &newStatus + i.Body.ChildID = newChildID return i }(), mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository) { + childRepo.On("GetChildByID", mock.Anything, newChildID).Return(&models.Child{ + ID: newChildID, + }, nil) + regRepo.On("UpdateRegistration", mock.Anything, mock.AnythingOfType("*models.UpdateRegistrationInput")).Return(&models.UpdateRegistrationOutput{ Body: models.Registration{ - ID: existingID, - ChildID: uuid.MustParse("30000000-0000-0000-0000-000000000001"), - GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), - EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), - Status: models.RegistrationStatusCancelled, - EventName: "STEM Club", - OccurrenceStartTime: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: existingID, + ChildID: newChildID, + GuardianID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + EventOccurrenceID: uuid.MustParse("70000000-0000-0000-0000-000000000001"), + Status: models.RegistrationStatusRegistered, + EventName: "STEM Club", + OccurrenceStartTime: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", }, }, nil) }, @@ -471,10 +604,14 @@ func TestHandler_UpdateRegistration(t *testing.T) { name: "registration not found", input: func() *models.UpdateRegistrationInput { i := &models.UpdateRegistrationInput{ID: existingID} - i.Body.Status = &newStatus + i.Body.ChildID = newChildID return i }(), mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository) { + childRepo.On("GetChildByID", mock.Anything, newChildID).Return(&models.Child{ + ID: newChildID, + }, nil) + regRepo.On("UpdateRegistration", mock.Anything, mock.AnythingOfType("*models.UpdateRegistrationInput")). Return(nil, &errs.HTTPError{ Code: errs.NotFound("Registration", "id", existingID.String()).Code, @@ -487,13 +624,13 @@ func TestHandler_UpdateRegistration(t *testing.T) { name: "invalid child_id on update", input: func() *models.UpdateRegistrationInput { i := &models.UpdateRegistrationInput{ID: existingID} - i.Body.ChildID = &newChildID + i.Body.ChildID = invalidChildID return i }(), mockSetup: func(regRepo *repomocks.MockRegistrationRepository, childRepo *repomocks.MockChildRepository) { - childRepo.On("GetChildByID", mock.Anything, newChildID). + childRepo.On("GetChildByID", mock.Anything, invalidChildID). Return(nil, &errs.HTTPError{ - Code: errs.NotFound("Child", "id", newChildID.String()).Code, + Code: errs.NotFound("Child", "id", invalidChildID.String()).Code, Message: "Child not found", }) }, @@ -509,9 +646,11 @@ func TestHandler_UpdateRegistration(t *testing.T) { mockChildRepo := new(repomocks.MockChildRepository) mockGuardianRepo := new(repomocks.MockGuardianRepository) mockEORepo := new(repomocks.MockEventOccurrenceRepository) + mockOrgRepo := new(repomocks.MockOrganizationRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRegRepo, mockChildRepo) - handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo) + handler := NewHandler(mockRegRepo, mockChildRepo, mockGuardianRepo, mockEORepo, mockOrgRepo, mockStripeClient) ctx := context.Background() registration, err := handler.UpdateRegistration(ctx, tt.input) @@ -522,12 +661,10 @@ func TestHandler_UpdateRegistration(t *testing.T) { } else { assert.NoError(t, err) assert.NotNil(t, registration) - if tt.input.Body.Status != nil { - assert.Equal(t, *tt.input.Body.Status, registration.Body.Status) - } + assert.Equal(t, tt.input.Body.ChildID, registration.Body.ChildID) } mockRegRepo.AssertExpectations(t) mockChildRepo.AssertExpectations(t) }) } -} +} \ No newline at end of file diff --git a/backend/internal/service/handler/registration/updateRegistration.go b/backend/internal/service/handler/registration/updateRegistration.go index c80727b8..9000997e 100644 --- a/backend/internal/service/handler/registration/updateRegistration.go +++ b/backend/internal/service/handler/registration/updateRegistration.go @@ -7,22 +7,8 @@ import ( ) func (h *Handler) UpdateRegistration(ctx context.Context, input *models.UpdateRegistrationInput) (*models.UpdateRegistrationOutput, error) { - if input.Body.ChildID != nil { - if _, err := h.ChildRepository.GetChildByID(ctx, *input.Body.ChildID); err != nil { - return nil, errs.BadRequest("Invalid child_id: child does not exist") - } - } - - if input.Body.GuardianID != nil { - if _, err := h.GuardianRepository.GetGuardianByID(ctx, *input.Body.GuardianID); err != nil { - return nil, errs.BadRequest("Invalid guardian_id: guardian does not exist") - } - } - - if input.Body.EventOccurrenceID != nil { - if _, err := h.EventOccurrenceRepository.GetEventOccurrenceByID(ctx, *input.Body.EventOccurrenceID); err != nil { - return nil, errs.BadRequest("Invalid event_occurrence_id: event occurrence does not exist") - } + if _, err := h.ChildRepository.GetChildByID(ctx, input.Body.ChildID); err != nil { + return nil, errs.BadRequest("Invalid child_id: child does not exist") } updated, err := h.RegistrationRepository.UpdateRegistration(ctx, input) diff --git a/backend/internal/service/handler/registration/updateRegistrationPaymentStatus.go b/backend/internal/service/handler/registration/updateRegistrationPaymentStatus.go new file mode 100644 index 00000000..5175f84a --- /dev/null +++ b/backend/internal/service/handler/registration/updateRegistrationPaymentStatus.go @@ -0,0 +1,15 @@ +package registration + +import ( + "context" + "skillspark/internal/models" +) + +func (h *Handler) UpdateRegistrationPaymentStatus(ctx context.Context, input *models.UpdateRegistrationPaymentStatusInput) (*models.UpdateRegistrationPaymentStatusOutput, error) { + updated, err := h.RegistrationRepository.UpdateRegistrationPaymentStatus(ctx, input) + if err != nil { + return nil, err + } + + return updated, nil +} \ No newline at end of file diff --git a/backend/internal/service/routes/event_occurrences_routes_test.go b/backend/internal/service/routes/event_occurrences_routes_test.go index db094c7d..3c53c6a3 100644 --- a/backend/internal/service/routes/event_occurrences_routes_test.go +++ b/backend/internal/service/routes/event_occurrences_routes_test.go @@ -50,7 +50,6 @@ func TestHumaValidation_GetEventOccurrenceById(t *testing.T) { eight := 8 twelve := 12 jpg := "events/robotics_workshop.jpg" - addr := "Suite 15" mid := uuid.MustParse("50000000-0000-0000-0000-000000000001") event := models.Event{ ID: uuid.MustParse("60000000-0000-0000-0000-000000000001"), @@ -65,6 +64,7 @@ func TestHumaValidation_GetEventOccurrenceById(t *testing.T) { UpdatedAt: time.Now(), } + addr := "Suite 15" location := models.Location{ ID: uuid.MustParse("10000000-0000-0000-0000-000000000004"), Latitude: 13.7650000, @@ -104,6 +104,7 @@ func TestHumaValidation_GetEventOccurrenceById(t *testing.T) { MaxAttendees: 15, Language: "en", CurrEnrolled: 8, + Price: 50000, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, nil) @@ -224,6 +225,7 @@ func TestHumaValidation_CreateEventOccurrence(t *testing.T) { "end_time": end, "max_attendees": 10, "language": "en", + "price": 50000, }, mockSetup: func(m *repomocks.MockEventOccurrenceRepository) { m.On( @@ -240,6 +242,7 @@ func TestHumaValidation_CreateEventOccurrence(t *testing.T) { MaxAttendees: 10, Language: "en", CurrEnrolled: 0, + Price: 50000, CreatedAt: time.Now(), UpdatedAt: time.Now(), }, nil) @@ -256,6 +259,7 @@ func TestHumaValidation_CreateEventOccurrence(t *testing.T) { "end_time": end, "max_attendees": 0, "language": "en", + "price": 50000, }, mockSetup: func(*repomocks.MockEventOccurrenceRepository) {}, statusCode: http.StatusUnprocessableEntity, @@ -270,6 +274,21 @@ func TestHumaValidation_CreateEventOccurrence(t *testing.T) { "end_time": end, "max_attendees": 10, "language": "e", + "price": 50000, + }, + mockSetup: func(*repomocks.MockEventOccurrenceRepository) {}, + statusCode: http.StatusUnprocessableEntity, + }, + { + name: "missing price", + payload: map[string]interface{}{ + "manager_id": nil, + "event_id": uuid.MustParse("60000000-0000-0000-0000-000000000001"), + "location_id": uuid.MustParse("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), + "start_time": start, + "end_time": end, + "max_attendees": 10, + "language": "en", }, mockSetup: func(*repomocks.MockEventOccurrenceRepository) {}, statusCode: http.StatusUnprocessableEntity, @@ -428,6 +447,7 @@ func TestHumaValidation_UpdateEventOccurrence(t *testing.T) { "max_attendees": max, "language": lang, "curr_enrolled": curr, + "price": 75000, }, mockSetup: func(m *repomocks.MockEventOccurrenceRepository) { m.On( @@ -444,6 +464,7 @@ func TestHumaValidation_UpdateEventOccurrence(t *testing.T) { MaxAttendees: 10, Language: "th", CurrEnrolled: 8, + Price: 75000, CreatedAt: time.Date(2026, time.January, 20, 21, 41, 2, 0, time.Local), UpdatedAt: time.Now(), }, nil) @@ -512,6 +533,7 @@ func TestHumaValidation_UpdateEventOccurrence(t *testing.T) { MaxAttendees: 15, Language: "en", CurrEnrolled: 5, + Price: 50000, CreatedAt: time.Date(2026, time.January, 20, 21, 41, 2, 0, time.Local), UpdatedAt: time.Date(2026, time.January, 20, 21, 41, 2, 0, time.Local), }, nil) @@ -538,4 +560,4 @@ func TestHumaValidation_UpdateEventOccurrence(t *testing.T) { mockRepo.AssertExpectations(t) }) } -} +} \ No newline at end of file diff --git a/backend/internal/service/routes/guardian_payment_methods.go b/backend/internal/service/routes/guardian_payment_methods.go new file mode 100644 index 00000000..9a1a7083 --- /dev/null +++ b/backend/internal/service/routes/guardian_payment_methods.go @@ -0,0 +1,64 @@ +package routes + +import ( + "context" + "net/http" + "skillspark/internal/models" + guardianpaymentmethod "skillspark/internal/service/handler/guardian-payment-method" + "skillspark/internal/storage" + "skillspark/internal/stripeClient" + + "github.com/danielgtaylor/huma/v2" +) + +func SetupGuardianPaymentMethodRoutes(api huma.API, repo *storage.Repository, sc stripeClient.StripeClientInterface) { + handler := guardianpaymentmethod.NewHandler( + repo.Guardian, + repo.GuardianPaymentMethod, + sc, + ) + + huma.Register(api, huma.Operation{ + OperationID: "get-guardian-payment-methods", + Method: http.MethodGet, + Path: "/api/v1/guardians/{guardian_id}/payment-methods", + Summary: "Get all payment methods for a guardian", + Description: "Returns list of saved payment methods for the guardian", + Tags: []string{"Guardian Payment Methods"}, + }, func(ctx context.Context, input *models.GetGuardianPaymentMethodsByGuardianIDInput) (*models.GetGuardianPaymentMethodsByGuardianIDOutput, error) { + return handler.GetPaymentMethodsByGuardianID(ctx, input) + }) + + huma.Register(api, huma.Operation{ + OperationID: "create-guardian-payment-method", + Method: http.MethodPost, + Path: "/api/v1/guardians/payment-methods", + Summary: "Save a new payment method for guardian", + Description: "Saves payment method details after frontend confirms with Stripe.js", + Tags: []string{"Guardian Payment Methods"}, + }, func(ctx context.Context, input *models.CreateGuardianPaymentMethodInput) (*models.CreateGuardianPaymentMethodOutput, error) { + return handler.CreateGuardianPaymentMethod(ctx, input) + }) + + huma.Register(api, huma.Operation{ + OperationID: "set-default-payment-method", + Method: http.MethodPatch, + Path: "/api/v1/guardians/{guardian_id}/payment-methods/{payment_method_id}/default", + Summary: "Set payment method as default", + Description: "Sets the specified payment method as the default for this guardian", + Tags: []string{"Guardian Payment Methods"}, + }, func(ctx context.Context, input *models.SetDefaultPaymentMethodInput) (*models.SetDefaultPaymentMethodOutput, error) { + return handler.SetDefaultGuardianPaymentMethod(ctx, input) + }) + + huma.Register(api, huma.Operation{ + OperationID: "delete-guardian-payment-method", + Method: http.MethodDelete, + Path: "/api/v1/guardians/payment-methods/{id}", + Summary: "Delete a saved payment method", + Description: "Removes payment method from guardian and detaches from Stripe", + Tags: []string{"Guardian Payment Methods"}, + }, func(ctx context.Context, input *models.DeleteGuardianPaymentMethodInput) (*models.DeleteGuardianPaymentMethodOutput, error) { + return handler.DeleteGuardianPaymentMethod(ctx, input) + }) +} \ No newline at end of file diff --git a/backend/internal/service/routes/guardian_payment_methods_test.go b/backend/internal/service/routes/guardian_payment_methods_test.go new file mode 100644 index 00000000..6e763598 --- /dev/null +++ b/backend/internal/service/routes/guardian_payment_methods_test.go @@ -0,0 +1,332 @@ +package routes_test + +import ( + "bytes" + "encoding/json" + "net/http" + "testing" + "time" + + "skillspark/internal/models" + "skillspark/internal/service/routes" + "skillspark/internal/storage" + repomocks "skillspark/internal/storage/repo-mocks" + stripemocks "skillspark/internal/stripeClient/mocks" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humafiber" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func setupGuardianPaymentMethodTestAPI( + guardianRepo *repomocks.MockGuardianRepository, + paymentMethodRepo *repomocks.MockGuardianPaymentMethodRepository, + stripeClient *stripemocks.MockStripeClient, +) (*fiber.App, huma.API) { + + app := fiber.New() + api := humafiber.New(app, huma.DefaultConfig("Test API", "1.0.0")) + + repo := &storage.Repository{ + Guardian: guardianRepo, + GuardianPaymentMethod: paymentMethodRepo, + } + + routes.SetupGuardianPaymentMethodRoutes(api, repo, stripeClient) + + return app, api +} + +func TestHumaValidation_GetGuardianPaymentMethods(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + guardianID string + mockSetup func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) + statusCode int + }{ + { + name: "valid guardian with payment methods", + guardianID: "88888888-8888-8888-8888-888888888888", + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + stripeCustomerID := "cus_test123" + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripeCustomerID: &stripeCustomerID, + }, nil) + + cardBrand := "visa" + cardLast4 := "4242" + pmr.On("GetPaymentMethodsByGuardianID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return([]models.GuardianPaymentMethod{ + { + ID: uuid.New(), + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripePaymentMethodID: "pm_test123", + CardBrand: &cardBrand, + CardLast4: &cardLast4, + IsDefault: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, + }, nil) + }, + statusCode: http.StatusOK, + }, + { + name: "invalid UUID", + guardianID: "not-a-uuid", + mockSetup: func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) {}, + statusCode: http.StatusUnprocessableEntity, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockGuardianRepo, mockPaymentMethodRepo) + + app, _ := setupGuardianPaymentMethodTestAPI(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + + req, err := http.NewRequest( + http.MethodGet, + "/api/v1/guardians/"+tt.guardianID+"/payment-methods", + nil, + ) + assert.NoError(t, err) + + resp, err := app.Test(req) + assert.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, tt.statusCode, resp.StatusCode) + mockGuardianRepo.AssertExpectations(t) + mockPaymentMethodRepo.AssertExpectations(t) + }) + } +} + +func TestHumaValidation_CreateGuardianPaymentMethod(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload map[string]interface{} + mockSetup func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) + statusCode int + }{ + { + name: "valid payload", + payload: map[string]interface{}{ + "guardian_id": "88888888-8888-8888-8888-888888888888", + "stripe_payment_method_id": "pm_test123", + "card_brand": "visa", + "card_last4": "4242", + "card_exp_month": 12, + "card_exp_year": 2027, + "is_default": true, + }, + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + stripeCustomerID := "cus_test123" + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripeCustomerID: &stripeCustomerID, + }, nil) + + pmr.On("CreateGuardianPaymentMethod", mock.Anything, mock.AnythingOfType("*models.CreateGuardianPaymentMethodInput")). + Return(&models.GuardianPaymentMethod{ + ID: uuid.New(), + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripePaymentMethodID: "pm_test123", + IsDefault: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }, nil) + }, + statusCode: http.StatusOK, + }, + { + name: "missing required fields", + payload: map[string]interface{}{ + "guardian_id": "88888888-8888-8888-8888-888888888888", + }, + mockSetup: func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) {}, + statusCode: http.StatusUnprocessableEntity, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockGuardianRepo, mockPaymentMethodRepo) + + app, _ := setupGuardianPaymentMethodTestAPI(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + + bodyBytes, err := json.Marshal(tt.payload) + assert.NoError(t, err) + + req, err := http.NewRequest( + http.MethodPost, + "/api/v1/guardians/payment-methods", + bytes.NewBuffer(bodyBytes), + ) + assert.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + assert.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, tt.statusCode, resp.StatusCode) + mockGuardianRepo.AssertExpectations(t) + mockPaymentMethodRepo.AssertExpectations(t) + }) + } +} + +func TestHumaValidation_SetDefaultPaymentMethod(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + guardianID string + paymentMethodID string + mockSetup func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) + statusCode int + }{ + { + name: "valid IDs", + guardianID: "88888888-8888-8888-8888-888888888888", + paymentMethodID: "11111111-1111-1111-1111-111111111111", + mockSetup: func(gr *repomocks.MockGuardianRepository, pmr *repomocks.MockGuardianPaymentMethodRepository) { + stripeCustomerID := "cus_test123" + gr.On("GetGuardianByID", mock.Anything, uuid.MustParse("88888888-8888-8888-8888-888888888888")). + Return(&models.Guardian{ + ID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + StripeCustomerID: &stripeCustomerID, + }, nil) + + pmr.On("UpdateGuardianPaymentMethod", mock.Anything, uuid.MustParse("11111111-1111-1111-1111-111111111111"), true). + Return(&models.GuardianPaymentMethod{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + GuardianID: uuid.MustParse("88888888-8888-8888-8888-888888888888"), + IsDefault: true, + }, nil) + }, + statusCode: http.StatusOK, + }, + { + name: "invalid guardian UUID", + guardianID: "not-a-uuid", + paymentMethodID: "11111111-1111-1111-1111-111111111111", + mockSetup: func(*repomocks.MockGuardianRepository, *repomocks.MockGuardianPaymentMethodRepository) {}, + statusCode: http.StatusUnprocessableEntity, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockGuardianRepo, mockPaymentMethodRepo) + + app, _ := setupGuardianPaymentMethodTestAPI(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + + req, err := http.NewRequest( + http.MethodPatch, + "/api/v1/guardians/"+tt.guardianID+"/payment-methods/"+tt.paymentMethodID+"/default", + nil, + ) + assert.NoError(t, err) + + resp, err := app.Test(req) + assert.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, tt.statusCode, resp.StatusCode) + mockGuardianRepo.AssertExpectations(t) + mockPaymentMethodRepo.AssertExpectations(t) + }) + } +} + +func TestHumaValidation_DeleteGuardianPaymentMethod(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + pmID string + mockSetup func(*repomocks.MockGuardianPaymentMethodRepository, *stripemocks.MockStripeClient) + statusCode int + }{ + { + name: "valid UUID", + pmID: "11111111-1111-1111-1111-111111111111", + mockSetup: func(pmr *repomocks.MockGuardianPaymentMethodRepository, sc *stripemocks.MockStripeClient) { + pmr.On("DeleteGuardianPaymentMethod", mock.Anything, uuid.MustParse("11111111-1111-1111-1111-111111111111")). + Return(&models.GuardianPaymentMethod{ + ID: uuid.MustParse("11111111-1111-1111-1111-111111111111"), + StripePaymentMethodID: "pm_test123", + }, nil) + + sc.On("DetachPaymentMethod", mock.Anything, "pm_test123").Return(nil) + }, + statusCode: http.StatusOK, + }, + { + name: "invalid UUID", + pmID: "not-a-uuid", + mockSetup: func(*repomocks.MockGuardianPaymentMethodRepository, *stripemocks.MockStripeClient) {}, + statusCode: http.StatusUnprocessableEntity, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + mockGuardianRepo := new(repomocks.MockGuardianRepository) + mockPaymentMethodRepo := new(repomocks.MockGuardianPaymentMethodRepository) + mockStripeClient := new(stripemocks.MockStripeClient) + tt.mockSetup(mockPaymentMethodRepo, mockStripeClient) + + app, _ := setupGuardianPaymentMethodTestAPI(mockGuardianRepo, mockPaymentMethodRepo, mockStripeClient) + + req, err := http.NewRequest( + http.MethodDelete, + "/api/v1/guardians/payment-methods/"+tt.pmID, + nil, + ) + assert.NoError(t, err) + + resp, err := app.Test(req) + assert.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + + assert.Equal(t, tt.statusCode, resp.StatusCode) + mockPaymentMethodRepo.AssertExpectations(t) + mockStripeClient.AssertExpectations(t) + }) + } +} \ No newline at end of file diff --git a/backend/internal/service/routes/guardian_routes_test.go b/backend/internal/service/routes/guardian_routes_test.go index 8ac5cc26..0a502ceb 100644 --- a/backend/internal/service/routes/guardian_routes_test.go +++ b/backend/internal/service/routes/guardian_routes_test.go @@ -7,11 +7,11 @@ import ( "testing" "time" - // "skillspark/internal/errs" "skillspark/internal/models" "skillspark/internal/service/routes" "skillspark/internal/storage" repomocks "skillspark/internal/storage/repo-mocks" + stripemocks "skillspark/internal/stripeClient/mocks" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humafiber" @@ -24,6 +24,7 @@ import ( func setupGuardianTestAPI( guardianRepo *repomocks.MockGuardianRepository, managerRepo *repomocks.MockManagerRepository, + stripeClient *stripemocks.MockStripeClient, ) (*fiber.App, huma.API) { app := fiber.New() @@ -35,7 +36,7 @@ func setupGuardianTestAPI( Manager: managerRepo, } - routes.SetupGuardiansRoutes(api, repo) + routes.SetupGuardiansRoutes(api, repo, stripeClient) return app, api } @@ -98,9 +99,10 @@ func TestHumaValidation_CreateGuardian(t *testing.T) { mockRepo := new(repomocks.MockGuardianRepository) mockManagerRepo := new(repomocks.MockManagerRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo, mockManagerRepo) - app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo) + app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo, mockStripeClient) bodyBytes, err := json.Marshal(tt.payload) assert.NoError(t, err) @@ -164,9 +166,10 @@ func TestHumaValidation_GetGuardianByID(t *testing.T) { mockRepo := new(repomocks.MockGuardianRepository) mockManagerRepo := new(repomocks.MockManagerRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo) + app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo, mockStripeClient) req, err := http.NewRequest( http.MethodGet, @@ -243,9 +246,10 @@ func TestHumaValidation_UpdateGuardian(t *testing.T) { mockRepo := new(repomocks.MockGuardianRepository) mockManagerRepo := new(repomocks.MockManagerRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo) + app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo, mockStripeClient) bodyBytes, err := json.Marshal(tt.payload) assert.NoError(t, err) @@ -306,9 +310,10 @@ func TestHumaValidation_DeleteGuardian(t *testing.T) { mockRepo := new(repomocks.MockGuardianRepository) mockManagerRepo := new(repomocks.MockManagerRepository) + mockStripeClient := new(stripemocks.MockStripeClient) tt.mockSetup(mockRepo) - app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo) + app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo, mockStripeClient) req, err := http.NewRequest( http.MethodDelete, @@ -325,63 +330,4 @@ func TestHumaValidation_DeleteGuardian(t *testing.T) { mockRepo.AssertExpectations(t) }) } -} - -func TestHumaValidation_GetGuardianByChildID(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - childID string - mockSetup func(*repomocks.MockGuardianRepository) - statusCode int - }{ - { - name: "valid UUID", - childID: "b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22", - mockSetup: func(m *repomocks.MockGuardianRepository) { - m.On( - "GetGuardianByChildID", - mock.Anything, - uuid.MustParse("b1eebc99-9c0b-4ef8-bb6d-6bb9bd380a22"), - ).Return(&models.Guardian{ - ID: uuid.New(), - }, nil) - }, - statusCode: http.StatusOK, - }, - { - name: "invalid UUID", - childID: "not-a-uuid", - mockSetup: func(*repomocks.MockGuardianRepository) {}, - statusCode: http.StatusUnprocessableEntity, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - mockRepo := new(repomocks.MockGuardianRepository) - mockManagerRepo := new(repomocks.MockManagerRepository) - tt.mockSetup(mockRepo) - - app, _ := setupGuardianTestAPI(mockRepo, mockManagerRepo) - - req, err := http.NewRequest( - http.MethodGet, - "/api/v1/guardians/child/"+tt.childID, - nil, - ) - assert.NoError(t, err) - - resp, err := app.Test(req) - assert.NoError(t, err) - defer func() { _ = resp.Body.Close() }() - - assert.Equal(t, tt.statusCode, resp.StatusCode) - mockRepo.AssertExpectations(t) - }) - } -} +} \ No newline at end of file diff --git a/backend/internal/service/routes/guardians.go b/backend/internal/service/routes/guardians.go index cf6afa3b..6ddacfc8 100644 --- a/backend/internal/service/routes/guardians.go +++ b/backend/internal/service/routes/guardians.go @@ -6,12 +6,13 @@ import ( "skillspark/internal/models" "skillspark/internal/service/handler/guardian" "skillspark/internal/storage" + "skillspark/internal/stripeClient" "github.com/danielgtaylor/huma/v2" ) -func SetupGuardiansRoutes(api huma.API, repo *storage.Repository) { - guardianHandler := guardian.NewHandler(repo.Guardian) +func SetupGuardiansRoutes(api huma.API, repo *storage.Repository, sc stripeClient.StripeClientInterface) { + guardianHandler := guardian.NewHandler(repo.Guardian, sc) huma.Register(api, huma.Operation{ OperationID: "get-guardian-by-id", Method: http.MethodGet, diff --git a/backend/internal/service/routes/organization_routes_test.go b/backend/internal/service/routes/organization_routes_test.go index bb121f24..8a5158ab 100644 --- a/backend/internal/service/routes/organization_routes_test.go +++ b/backend/internal/service/routes/organization_routes_test.go @@ -13,6 +13,7 @@ import ( "skillspark/internal/models" "skillspark/internal/s3_client" s3mocks "skillspark/internal/s3_client/mocks" + // "skillspark/internal/service/client" "skillspark/internal/storage" repomocks "skillspark/internal/storage/repo-mocks" "skillspark/internal/utils" diff --git a/backend/internal/service/routes/payments.go b/backend/internal/service/routes/payments.go new file mode 100644 index 00000000..11ce75d4 --- /dev/null +++ b/backend/internal/service/routes/payments.go @@ -0,0 +1,76 @@ +package routes + +import ( + "context" + "net/http" + "skillspark/internal/models" + "skillspark/internal/service/handler/payment" + "skillspark/internal/storage" + "skillspark/internal/stripeClient" + + "github.com/danielgtaylor/huma/v2" +) + +func SetupPaymentRoutes(api huma.API, repo *storage.Repository, sc stripeClient.StripeClientInterface) { + paymentHandler := payment.NewHandler( + repo.Organization, + repo.Manager, + repo.Registration, + repo.Location, + repo.Guardian, + sc, + ) + + huma.Register(api, huma.Operation{ + OperationID: "create-org-stripe-account", + Method: http.MethodPost, + Path: "/api/v1/stripe/orgaccount", + Summary: "Create a new Stripe account for an organization", + Description: "Create a new Stripe account for an organization", + Tags: []string{"Payments"}, + }, func(ctx context.Context, input *models.CreateOrgStripeAccountInput) (*models.Organization, error) { + return paymentHandler.CreateOrgStripeAccount(ctx, input) + }) + + huma.Register(api, huma.Operation{ + OperationID: "create-org-stripe-onboarding-link", + Method: http.MethodPost, + Path: "/api/v1/stripe/onboarding", + Summary: "Creates an onboarding link for a Stripe account", + Description: "Creates an onboarding link for a Stripe account", + Tags: []string{"Payments"}, +}, func(ctx context.Context, input *models.CreateStripeOnboardingLinkInput) (*models.CreateStripeOnboardingLinkOutput, error) { + return paymentHandler.CreateAccountOnboardingLink(ctx, input) +}) + + huma.Register(api, huma.Operation{ + OperationID: "create-org-login-link", + Method: http.MethodPost, + Path: "/api/v1/organizations/{organization_id}/stripe-login", + Summary: "Create Stripe dashboard login link for organization", + Description: "Generates a login link for organization to access their Stripe Express dashboard", + Tags: []string{"Payments"}, + }, func(ctx context.Context, input *models.CreateOrgLoginLinkInput) (*models.CreateOrgLoginLinkOutput, error) { + return paymentHandler.CreateOrgLoginLink(ctx, input) + }) + + huma.Register(api, huma.Operation{ + OperationID: "create-guardian-setup-intent", + Method: http.MethodPost, + Path: "/api/v1/guardians/{guardian_id}/setup-intent", + Summary: "Create a SetupIntent for guardian to add payment method", + Description: "Creates a Stripe SetupIntent and returns client_secret for frontend to collect card details", + Tags: []string{"Payments"}, + }, func(ctx context.Context, input *models.CreateSetupIntentInput) (*models.CreateSetupIntentOutput, error) { + return paymentHandler.CreateSetupIntent(ctx, input) + }) + + // huma.Register(api, huma.Operation{ + // OperationID: "stripe-webhook", + // Method: http.MethodPost, + // Path: "/webhooks/stripe", + // Summary: "Handle Stripe webhooks", + // Tags: []string{"Webhooks"}, + // Security: []map[string][]string{}, + // }, paymentHandler.HandleStripeWebhook) +} \ No newline at end of file diff --git a/backend/internal/service/routes/registration.go b/backend/internal/service/routes/registration.go index 1cfca33a..95bbbee5 100644 --- a/backend/internal/service/routes/registration.go +++ b/backend/internal/service/routes/registration.go @@ -6,12 +6,13 @@ import ( "skillspark/internal/models" "skillspark/internal/service/handler/registration" "skillspark/internal/storage" + "skillspark/internal/stripeClient" "github.com/danielgtaylor/huma/v2" ) -func SetupRegistrationRoutes(api huma.API, repo *storage.Repository) { - registrationHandler := registration.NewHandler(repo.Registration, repo.Child, repo.Guardian, repo.EventOccurrence) +func SetupRegistrationRoutes(api huma.API, repo *storage.Repository, sc stripeClient.StripeClientInterface) { + registrationHandler := registration.NewHandler(repo.Registration, repo.Child, repo.Guardian, repo.EventOccurrence, repo.Organization, sc) huma.Register(api, huma.Operation{ OperationID: "create-registration", @@ -73,9 +74,31 @@ func SetupRegistrationRoutes(api huma.API, repo *storage.Repository) { Method: http.MethodPatch, Path: "/api/v1/registrations/{id}", Summary: "Update a registration", - Description: "Update an existing registration's details", + Description: "Update the child associated with a registration", Tags: []string{"Registrations"}, }, func(ctx context.Context, input *models.UpdateRegistrationInput) (*models.UpdateRegistrationOutput, error) { return registrationHandler.UpdateRegistration(ctx, input) }) -} + + huma.Register(api, huma.Operation{ + OperationID: "cancel-registration", + Method: http.MethodPost, + Path: "/api/v1/registrations/{id}/cancel", + Summary: "Cancel a registration", + Description: "Cancel a registration and process refund if applicable", + Tags: []string{"Registrations"}, + }, func(ctx context.Context, input *models.CancelRegistrationInput) (*models.CancelRegistrationOutput, error) { + return registrationHandler.CancelRegistration(ctx, input) + }) + + huma.Register(api, huma.Operation{ + OperationID: "update-registration-payment-status", + Method: http.MethodPatch, + Path: "/api/v1/registrations/{id}/payment-status", + Summary: "Update registration payment status", + Description: "Update the payment intent status for a registration (typically called by webhooks)", + Tags: []string{"Registrations"}, + }, func(ctx context.Context, input *models.UpdateRegistrationPaymentStatusInput) (*models.UpdateRegistrationPaymentStatusOutput, error) { + return registrationHandler.UpdateRegistrationPaymentStatus(ctx, input) + }) +} \ No newline at end of file diff --git a/backend/internal/service/routes/registration_routes_test.go b/backend/internal/service/routes/registration_routes_test.go index 8bee6118..6cc2cbcb 100644 --- a/backend/internal/service/routes/registration_routes_test.go +++ b/backend/internal/service/routes/registration_routes_test.go @@ -11,6 +11,7 @@ import ( "skillspark/internal/models" "skillspark/internal/storage" repomocks "skillspark/internal/storage/repo-mocks" + stripemocks "skillspark/internal/stripeClient/mocks" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humafiber" @@ -25,6 +26,7 @@ func setupRegistrationTestAPI( childRepo *repomocks.MockChildRepository, guardianRepo *repomocks.MockGuardianRepository, eventOccurrenceRepo *repomocks.MockEventOccurrenceRepository, + ) (*fiber.App, huma.API) { app := fiber.New() @@ -38,7 +40,9 @@ func setupRegistrationTestAPI( EventOccurrence: eventOccurrenceRepo, } - SetupRegistrationRoutes(api, repo) + sc := new(stripemocks.MockStripeClient) + + SetupRegistrationRoutes(api, repo, sc) return app, api } diff --git a/backend/internal/service/server.go b/backend/internal/service/server.go index a7469a3d..b3198c8a 100644 --- a/backend/internal/service/server.go +++ b/backend/internal/service/server.go @@ -2,6 +2,8 @@ package service import ( "context" + "log" + "os" "skillspark/internal/auth" "skillspark/internal/config" "skillspark/internal/errs" @@ -9,6 +11,7 @@ import ( "skillspark/internal/service/routes" "skillspark/internal/storage" "skillspark/internal/storage/postgres" + "skillspark/internal/stripeClient" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humafiber" @@ -90,14 +93,20 @@ func SetupApp(config config.Config, repo *storage.Repository, s3Client *s3_clien return c.Status(fiber.StatusOK).SendString("Welcome to SkillSpark!") }) + stripeAPIKey := os.Getenv("STRIPE_SECRET_TEST_KEY") + if stripeAPIKey == "" { + log.Fatal("STRIPE_SECRET_KEY environment variable is required") + } + stripeClient := stripeClient.NewStripeClient(stripeAPIKey) + // Register Huma endpoints - setupHumaRoutes(humaAPI, repo, config, s3Client) + setupHumaRoutes(humaAPI, repo, config, s3Client, stripeClient) return app, humaAPI } // Setup Huma routes -func setupHumaRoutes(api huma.API, repo *storage.Repository, config config.Config, s3Client *s3_client.Client) { +func setupHumaRoutes(api huma.API, repo *storage.Repository, config config.Config, s3Client *s3_client.Client, sc stripeClient.StripeClientInterface) { routes.SetupBaseRoutes(api) routes.SetupLocationsRoutes(api, repo) routes.SetupExamplesRoutes(api, repo) @@ -105,9 +114,11 @@ func setupHumaRoutes(api huma.API, repo *storage.Repository, config config.Confi routes.SetupSchoolsRoutes(api, repo) routes.SetupEventRoutes(api, repo, s3Client) routes.SetupManagerRoutes(api, repo) - routes.SetupRegistrationRoutes(api, repo) - routes.SetupGuardiansRoutes(api, repo) + routes.SetupRegistrationRoutes(api, repo, sc) + routes.SetupGuardiansRoutes(api, repo, sc) routes.SetupChildRoutes(api, repo) routes.SetupEventOccurrencesRoutes(api, repo) routes.SetupAuthRoutes(api, repo, config) + routes.SetupPaymentRoutes(api, repo, sc) + routes.SetupGuardianPaymentMethodRoutes(api, repo, sc) } diff --git a/backend/internal/storage/postgres/schema/event-occurrence/create.go b/backend/internal/storage/postgres/schema/event-occurrence/create.go index 010aa97f..bea611e7 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/create.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/create.go @@ -23,6 +23,7 @@ func (r *EventOccurrenceRepository) CreateEventOccurrence(ctx context.Context, i input.Body.EndTime, input.Body.MaxAttendees, input.Body.Language, + input.Body.Price, ) var createdEventOccurrence models.EventOccurrence @@ -39,6 +40,7 @@ func (r *EventOccurrenceRepository) CreateEventOccurrence(ctx context.Context, i &createdEventOccurrence.CreatedAt, &createdEventOccurrence.UpdatedAt, &createdEventOccurrence.Status, + &createdEventOccurrence.Price, // event fields &createdEventOccurrence.Event.ID, @@ -73,4 +75,4 @@ func (r *EventOccurrenceRepository) CreateEventOccurrence(ctx context.Context, i } return &createdEventOccurrence, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/create_test.go b/backend/internal/storage/postgres/schema/event-occurrence/create_test.go index e97d4218..5fd9de3d 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/create_test.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/create_test.go @@ -35,6 +35,7 @@ func TestEventOccurrenceRepository_CreateEventOccurrence(t *testing.T) { input.Body.EndTime = end input.Body.MaxAttendees = 10 input.Body.Language = "en" + input.Body.Price = 50000 return input }() @@ -50,6 +51,7 @@ func TestEventOccurrenceRepository_CreateEventOccurrence(t *testing.T) { assert.Equal(t, 10, eventOccurrence.MaxAttendees) assert.Equal(t, "en", eventOccurrence.Language) assert.Equal(t, 0, eventOccurrence.CurrEnrolled) + assert.Equal(t, 50000, eventOccurrence.Price) // check created event occurrence in database id := eventOccurrence.ID @@ -64,4 +66,5 @@ func TestEventOccurrenceRepository_CreateEventOccurrence(t *testing.T) { assert.Equal(t, 10, retrievedEventOccurrence.MaxAttendees) assert.Equal(t, "en", retrievedEventOccurrence.Language) assert.Equal(t, 0, retrievedEventOccurrence.CurrEnrolled) -} + assert.Equal(t, 50000, retrievedEventOccurrence.Price) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/get_all.go b/backend/internal/storage/postgres/schema/event-occurrence/get_all.go index 93ea9ad5..dc94c877 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/get_all.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/get_all.go @@ -47,6 +47,7 @@ func scanEventOccurrence(row pgx.CollectableRow) (models.EventOccurrence, error) &createdEventOccurrence.CreatedAt, &createdEventOccurrence.UpdatedAt, &createdEventOccurrence.Status, + &createdEventOccurrence.Price, // event fields &createdEventOccurrence.Event.ID, @@ -75,4 +76,4 @@ func scanEventOccurrence(row pgx.CollectableRow) (models.EventOccurrence, error) &createdEventOccurrence.Location.UpdatedAt, ) return createdEventOccurrence, err -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/get_all_test.go b/backend/internal/storage/postgres/schema/event-occurrence/get_all_test.go index 56d5322c..5ea020a8 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/get_all_test.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/get_all_test.go @@ -33,6 +33,10 @@ func TestEventOccurrenceRepository_GetAllEventOccurrences(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, eventOccurrences) assert.Equal(t, count, int64(len(eventOccurrences))) + + if len(eventOccurrences) > 0 { + assert.GreaterOrEqual(t, eventOccurrences[0].Price, 0) + } } func TestEventOccurrenceRepository_GetAllEventOccurrences_Pagination(t *testing.T) { @@ -59,6 +63,10 @@ func TestEventOccurrenceRepository_GetAllEventOccurrences_Pagination(t *testing. assert.Nil(t, err1) assert.NotNil(t, eventOccurrences1) assert.Equal(t, 4, len(eventOccurrences1)) + + for _, eo := range eventOccurrences1 { + assert.GreaterOrEqual(t, eo.Price, 0) + } // test page 2 with limit 4 pagination2 := utils.Pagination{Page: 2, Limit: 4} @@ -66,6 +74,10 @@ func TestEventOccurrenceRepository_GetAllEventOccurrences_Pagination(t *testing. assert.Nil(t, err2) assert.NotNil(t, eventOccurrences2) assert.Equal(t, 4, len(eventOccurrences2)) + + for _, eo := range eventOccurrences2 { + assert.GreaterOrEqual(t, eo.Price, 0) + } // test page 3 with limit 4 pagination3 := utils.Pagination{Page: 3, Limit: 4} @@ -73,4 +85,8 @@ func TestEventOccurrenceRepository_GetAllEventOccurrences_Pagination(t *testing. assert.Nil(t, err3) assert.NotNil(t, eventOccurrences3) assert.Equal(t, 4, len(eventOccurrences3)) -} + + for _, eo := range eventOccurrences3 { + assert.GreaterOrEqual(t, eo.Price, 0) + } +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/get_by_id.go b/backend/internal/storage/postgres/schema/event-occurrence/get_by_id.go index fc87112d..47245c8d 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/get_by_id.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/get_by_id.go @@ -33,6 +33,7 @@ func (r *EventOccurrenceRepository) GetEventOccurrenceByID(ctx context.Context, &eventOccurrence.CreatedAt, &eventOccurrence.UpdatedAt, &eventOccurrence.Status, + &eventOccurrence.Price, // event fields &eventOccurrence.Event.ID, @@ -71,4 +72,4 @@ func (r *EventOccurrenceRepository) GetEventOccurrenceByID(ctx context.Context, } return &eventOccurrence, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/get_by_id_test.go b/backend/internal/storage/postgres/schema/event-occurrence/get_by_id_test.go index 048b2bc9..79ff2103 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/get_by_id_test.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/get_by_id_test.go @@ -19,9 +19,9 @@ func TestEventOccurrenceRepository_GetEventOccurrenceById(t *testing.T) { ctx := context.Background() t.Parallel() - // check that get by id works for 3 different event occurrences - eventOccurrence1, err := repo.GetEventOccurrenceByID(ctx, uuid.MustParse("70000000-0000-0000-0000-000000000001")) mid := uuid.MustParse("50000000-0000-0000-0000-000000000001") + + eventOccurrence1, err := repo.GetEventOccurrenceByID(ctx, uuid.MustParse("70000000-0000-0000-0000-000000000001")) assert.Nil(t, err) assert.NotNil(t, eventOccurrence1) assert.Equal(t, &mid, eventOccurrence1.ManagerId) @@ -30,6 +30,7 @@ func TestEventOccurrenceRepository_GetEventOccurrenceById(t *testing.T) { assert.Equal(t, 15, eventOccurrence1.MaxAttendees) assert.Equal(t, "en", eventOccurrence1.Language) assert.Equal(t, 8, eventOccurrence1.CurrEnrolled) + assert.GreaterOrEqual(t, eventOccurrence1.Price, 0) eventOccurrence2, err := repo.GetEventOccurrenceByID(ctx, uuid.MustParse("70000000-0000-0000-0000-000000000003")) assert.Nil(t, err) @@ -40,15 +41,16 @@ func TestEventOccurrenceRepository_GetEventOccurrenceById(t *testing.T) { assert.Equal(t, 12, eventOccurrence2.MaxAttendees) assert.Equal(t, "en", eventOccurrence2.Language) assert.Equal(t, 10, eventOccurrence2.CurrEnrolled) + assert.GreaterOrEqual(t, eventOccurrence2.Price, 0) eventOccurrence3, err := repo.GetEventOccurrenceByID(ctx, uuid.MustParse("70000000-0000-0000-0000-000000000002")) assert.Nil(t, err) - assert.NotNil(t, eventOccurrence1) + assert.NotNil(t, eventOccurrence3) assert.Equal(t, &mid, eventOccurrence3.ManagerId) assert.Equal(t, uuid.MustParse("60000000-0000-0000-0000-000000000001"), eventOccurrence3.Event.ID) assert.Equal(t, uuid.MustParse("10000000-0000-0000-0000-000000000004"), eventOccurrence3.Location.ID) assert.Equal(t, 15, eventOccurrence3.MaxAttendees) assert.Equal(t, "en", eventOccurrence3.Language) assert.Equal(t, 5, eventOccurrence3.CurrEnrolled) - -} + assert.GreaterOrEqual(t, eventOccurrence3.Price, 0) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/sql/create.sql b/backend/internal/storage/postgres/schema/event-occurrence/sql/create.sql index a402192a..2e3de3e9 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/sql/create.sql +++ b/backend/internal/storage/postgres/schema/event-occurrence/sql/create.sql @@ -1,7 +1,7 @@ WITH new_row AS ( - INSERT INTO event_occurrence (manager_id, event_id, location_id, start_time, end_time, max_attendees, language) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, manager_id, event_id, location_id, start_time, end_time, max_attendees, language, curr_enrolled, created_at, updated_at, status + INSERT INTO event_occurrence (manager_id, event_id, location_id, start_time, end_time, max_attendees, language, price) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, manager_id, event_id, location_id, start_time, end_time, max_attendees, language, curr_enrolled, created_at, updated_at, status, price ) SELECT eo.id, @@ -14,6 +14,7 @@ SELECT eo.created_at, eo.updated_at, eo.status, + eo.price, e.id, e.title, diff --git a/backend/internal/storage/postgres/schema/event-occurrence/sql/get_all.sql b/backend/internal/storage/postgres/schema/event-occurrence/sql/get_all.sql index 7d70d432..30368162 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/sql/get_all.sql +++ b/backend/internal/storage/postgres/schema/event-occurrence/sql/get_all.sql @@ -8,7 +8,8 @@ SELECT eo.curr_enrolled, eo.created_at, eo.updated_at, - eo.status, + eo.status, + eo.price, e.id, e.title, diff --git a/backend/internal/storage/postgres/schema/event-occurrence/sql/get_by_id.sql b/backend/internal/storage/postgres/schema/event-occurrence/sql/get_by_id.sql index 58b7af8f..f1a62f96 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/sql/get_by_id.sql +++ b/backend/internal/storage/postgres/schema/event-occurrence/sql/get_by_id.sql @@ -8,7 +8,8 @@ SELECT eo.curr_enrolled, eo.created_at, eo.updated_at, - eo.status, + eo.status, + eo.price, e.id, e.title, diff --git a/backend/internal/storage/postgres/schema/event-occurrence/sql/update.sql b/backend/internal/storage/postgres/schema/event-occurrence/sql/update.sql index e4d7e82c..a1434ee7 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/sql/update.sql +++ b/backend/internal/storage/postgres/schema/event-occurrence/sql/update.sql @@ -9,9 +9,10 @@ WITH updated_row AS ( max_attendees = COALESCE($7, eo.max_attendees), language = COALESCE($8, eo.language), curr_enrolled = COALESCE($9, eo.curr_enrolled), + price = COALESCE($10, eo.price), updated_at = NOW() WHERE eo.id = $1 - RETURNING eo.id, eo.manager_id, eo.event_id, eo.location_id, eo.start_time, eo.end_time, eo.max_attendees, eo.language, eo.curr_enrolled, eo.created_at, eo.updated_at, eo.status + RETURNING eo.id, eo.manager_id, eo.event_id, eo.location_id, eo.start_time, eo.end_time, eo.max_attendees, eo.language, eo.curr_enrolled, eo.created_at, eo.updated_at, eo.status, eo.price ) SELECT eo.id, @@ -23,7 +24,8 @@ SELECT eo.curr_enrolled, eo.created_at, eo.updated_at, - eo.status, + eo.status, + eo.price, e.id, e.title, diff --git a/backend/internal/storage/postgres/schema/event-occurrence/update.go b/backend/internal/storage/postgres/schema/event-occurrence/update.go index 3688ce55..fcc428a6 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/update.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/update.go @@ -26,6 +26,7 @@ func (r *EventOccurrenceRepository) UpdateEventOccurrence(ctx context.Context, i input.Body.MaxAttendees, input.Body.Language, input.Body.CurrEnrolled, + input.Body.Price, ) var updatedEventOccurrence models.EventOccurrence @@ -42,6 +43,7 @@ func (r *EventOccurrenceRepository) UpdateEventOccurrence(ctx context.Context, i &updatedEventOccurrence.CreatedAt, &updatedEventOccurrence.UpdatedAt, &updatedEventOccurrence.Status, + &updatedEventOccurrence.Price, // event fields &updatedEventOccurrence.Event.ID, @@ -76,4 +78,4 @@ func (r *EventOccurrenceRepository) UpdateEventOccurrence(ctx context.Context, i } return &updatedEventOccurrence, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/event-occurrence/update_test.go b/backend/internal/storage/postgres/schema/event-occurrence/update_test.go index 040a287b..98c91050 100644 --- a/backend/internal/storage/postgres/schema/event-occurrence/update_test.go +++ b/backend/internal/storage/postgres/schema/event-occurrence/update_test.go @@ -29,6 +29,7 @@ func TestEventOccurrenceRepository_UpdateEventOccurrence(t *testing.T) { max := 10 lang := "th" curr := 8 + price := 75000 eventOccurrenceInput := func() *models.UpdateEventOccurrenceInput { input := &models.UpdateEventOccurrenceInput{} @@ -41,6 +42,7 @@ func TestEventOccurrenceRepository_UpdateEventOccurrence(t *testing.T) { input.Body.MaxAttendees = &max input.Body.Language = &lang input.Body.CurrEnrolled = &curr + input.Body.Price = &price return input }() @@ -56,6 +58,7 @@ func TestEventOccurrenceRepository_UpdateEventOccurrence(t *testing.T) { assert.Equal(t, *eventOccurrenceInput.Body.MaxAttendees, eventOccurrence.MaxAttendees) assert.Equal(t, *eventOccurrenceInput.Body.Language, eventOccurrence.Language) assert.Equal(t, *eventOccurrenceInput.Body.CurrEnrolled, eventOccurrence.CurrEnrolled) + assert.Equal(t, *eventOccurrenceInput.Body.Price, eventOccurrence.Price) // check updated event occurrence in database id := eventOccurrence.ID @@ -70,4 +73,5 @@ func TestEventOccurrenceRepository_UpdateEventOccurrence(t *testing.T) { assert.Equal(t, 10, retrievedEventOccurrence.MaxAttendees) assert.Equal(t, "th", retrievedEventOccurrence.Language) assert.Equal(t, 8, retrievedEventOccurrence.CurrEnrolled) -} + assert.Equal(t, 75000, retrievedEventOccurrence.Price) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/create.go b/backend/internal/storage/postgres/schema/guardian-payment-method/create.go new file mode 100644 index 00000000..5ac8222e --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/create.go @@ -0,0 +1,51 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" +) + +func (r *GuardianPaymentMethodRepository) CreateGuardianPaymentMethod( + ctx context.Context, + input *models.CreateGuardianPaymentMethodInput, +) (*models.GuardianPaymentMethod, error) { + query, err := schema.ReadSQLBaseScript("guardian-payment-method/sql/create.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, + input.Body.GuardianID, + input.Body.StripePaymentMethodID, + input.Body.CardBrand, + input.Body.CardLast4, + input.Body.CardExpMonth, + input.Body.CardExpYear, + input.Body.IsDefault, + ) + + var createdPaymentMethod models.GuardianPaymentMethod + + err = row.Scan( + &createdPaymentMethod.ID, + &createdPaymentMethod.GuardianID, + &createdPaymentMethod.StripePaymentMethodID, + &createdPaymentMethod.CardBrand, + &createdPaymentMethod.CardLast4, + &createdPaymentMethod.CardExpMonth, + &createdPaymentMethod.CardExpYear, + &createdPaymentMethod.IsDefault, + &createdPaymentMethod.CreatedAt, + &createdPaymentMethod.UpdatedAt, + ) + + if err != nil { + errr := errs.InternalServerError("Failed to create guardian payment method: ", err.Error()) + return nil, &errr + } + + return &createdPaymentMethod, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/create_test.go b/backend/internal/storage/postgres/schema/guardian-payment-method/create_test.go new file mode 100644 index 00000000..54996333 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/create_test.go @@ -0,0 +1,148 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateGuardianPaymentMethod(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + cardBrand := "visa" + cardLast4 := "4242" + expMonth := 12 + expYear := 2027 + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_visa_4242" + input.Body.CardBrand = &cardBrand + input.Body.CardLast4 = &cardLast4 + input.Body.CardExpMonth = &expMonth + input.Body.CardExpYear = &expYear + input.Body.IsDefault = true + + created, err := repo.CreateGuardianPaymentMethod(ctx, input) + + require.Nil(t, err) + require.NotNil(t, created) + assert.NotEqual(t, uuid.Nil, created.ID) + assert.Equal(t, guardianID, created.GuardianID) + assert.Equal(t, "pm_test_visa_4242", created.StripePaymentMethodID) + assert.Equal(t, "visa", *created.CardBrand) + assert.Equal(t, "4242", *created.CardLast4) + assert.Equal(t, 12, *created.CardExpMonth) + assert.Equal(t, 2027, *created.CardExpYear) + assert.True(t, created.IsDefault) + assert.NotNil(t, created.CreatedAt) + assert.NotNil(t, created.UpdatedAt) +} + +func TestCreateGuardianPaymentMethod_NonDefault(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + cardBrand := "mastercard" + cardLast4 := "5555" + expMonth := 6 + expYear := 2028 + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_mastercard_5555" + input.Body.CardBrand = &cardBrand + input.Body.CardLast4 = &cardLast4 + input.Body.CardExpMonth = &expMonth + input.Body.CardExpYear = &expYear + input.Body.IsDefault = false + + created, err := repo.CreateGuardianPaymentMethod(ctx, input) + + require.Nil(t, err) + require.NotNil(t, created) + assert.False(t, created.IsDefault) +} + +func TestCreateGuardianPaymentMethod_MinimalInfo(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_minimal" + input.Body.IsDefault = false + + created, err := repo.CreateGuardianPaymentMethod(ctx, input) + + require.Nil(t, err) + require.NotNil(t, created) + assert.Equal(t, "pm_test_minimal", created.StripePaymentMethodID) + assert.Nil(t, created.CardBrand) + assert.Nil(t, created.CardLast4) + assert.Nil(t, created.CardExpMonth) + assert.Nil(t, created.CardExpYear) +} + +func TestCreateGuardianPaymentMethod_MultipleCards(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input1 := &models.CreateGuardianPaymentMethodInput{} + input1.Body.GuardianID = guardianID + input1.Body.StripePaymentMethodID = "pm_test_card1" + input1.Body.IsDefault = true + + created1, err := repo.CreateGuardianPaymentMethod(ctx, input1) + require.Nil(t, err) + assert.True(t, created1.IsDefault) + + input2 := &models.CreateGuardianPaymentMethodInput{} + input2.Body.GuardianID = guardianID + input2.Body.StripePaymentMethodID = "pm_test_card2" + input2.Body.IsDefault = false + + created2, err := repo.CreateGuardianPaymentMethod(ctx, input2) + require.Nil(t, err) + assert.False(t, created2.IsDefault) + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + require.Nil(t, err) + assert.GreaterOrEqual(t, len(paymentMethods), 2) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/delete.go b/backend/internal/storage/postgres/schema/guardian-payment-method/delete.go new file mode 100644 index 00000000..9b340055 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/delete.go @@ -0,0 +1,51 @@ +package guardianpaymentmethod + +import ( + "context" + "errors" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (r *GuardianPaymentMethodRepository) DeleteGuardianPaymentMethod( + ctx context.Context, + id uuid.UUID, +) (*models.GuardianPaymentMethod, error) { + query, err := schema.ReadSQLBaseScript("guardian-payment-method/sql/delete.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, id) + + var deletedPaymentMethod models.GuardianPaymentMethod + + err = row.Scan( + &deletedPaymentMethod.ID, + &deletedPaymentMethod.GuardianID, + &deletedPaymentMethod.StripePaymentMethodID, + &deletedPaymentMethod.CardBrand, + &deletedPaymentMethod.CardLast4, + &deletedPaymentMethod.CardExpMonth, + &deletedPaymentMethod.CardExpYear, + &deletedPaymentMethod.IsDefault, + &deletedPaymentMethod.CreatedAt, + &deletedPaymentMethod.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + errr := errs.NotFound("Guardian Payment Method", "id", id) + return nil, &errr + } + errr := errs.InternalServerError("Failed to delete payment method: ", err.Error()) + return nil, &errr + } + + return &deletedPaymentMethod, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/delete_test.go b/backend/internal/storage/postgres/schema/guardian-payment-method/delete_test.go new file mode 100644 index 00000000..faa4da8f --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/delete_test.go @@ -0,0 +1,122 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeleteGuardianPaymentMethod(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_delete" + input.Body.IsDefault = false + + created, createErr := repo.CreateGuardianPaymentMethod(ctx, input) + require.Nil(t, createErr) + require.NotNil(t, created) + + deleted, deleteErr := repo.DeleteGuardianPaymentMethod(ctx, created.ID) + + require.Nil(t, deleteErr) + require.NotNil(t, deleted) + assert.Equal(t, created.ID, deleted.ID) + assert.Equal(t, "pm_test_delete", deleted.StripePaymentMethodID) + assert.Equal(t, guardianID, deleted.GuardianID) + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + require.Nil(t, err) + + for _, pm := range paymentMethods { + assert.NotEqual(t, created.ID, pm.ID) + } +} + +func TestDeleteGuardianPaymentMethod_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + deleted, err := repo.DeleteGuardianPaymentMethod(ctx, uuid.New()) + + require.NotNil(t, err) + assert.Nil(t, deleted) +} + +func TestDeleteGuardianPaymentMethod_AlreadyDeleted(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_double_delete" + input.Body.IsDefault = false + + created, createErr := repo.CreateGuardianPaymentMethod(ctx, input) + require.Nil(t, createErr) + + deleted1, deleteErr1 := repo.DeleteGuardianPaymentMethod(ctx, created.ID) + require.Nil(t, deleteErr1) + require.NotNil(t, deleted1) + + deleted2, deleteErr2 := repo.DeleteGuardianPaymentMethod(ctx, created.ID) + require.NotNil(t, deleteErr2) + assert.Nil(t, deleted2) +} + +func TestDeleteGuardianPaymentMethod_DefaultCard(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_delete_default" + input.Body.IsDefault = true + + created, createErr := repo.CreateGuardianPaymentMethod(ctx, input) + require.Nil(t, createErr) + require.True(t, created.IsDefault) + + deleted, deleteErr := repo.DeleteGuardianPaymentMethod(ctx, created.ID) + + require.Nil(t, deleteErr) + require.NotNil(t, deleted) + assert.True(t, deleted.IsDefault) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/get_by_guardian_id.go b/backend/internal/storage/postgres/schema/guardian-payment-method/get_by_guardian_id.go new file mode 100644 index 00000000..38404a2d --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/get_by_guardian_id.go @@ -0,0 +1,56 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (r *GuardianPaymentMethodRepository) GetPaymentMethodsByGuardianID( + ctx context.Context, + guardianID uuid.UUID, +) ([]models.GuardianPaymentMethod, error) { + query, err := schema.ReadSQLBaseScript("guardian-payment-method/sql/get_by_guardian_id.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + rows, err := r.db.Query(ctx, query, guardianID) + if err != nil { + errr := errs.InternalServerError("Failed to fetch payment methods: ", err.Error()) + return nil, &errr + } + defer rows.Close() + + paymentMethods, err := pgx.CollectRows(rows, scanGuardianPaymentMethod) + if err != nil { + errr := errs.InternalServerError("Failed to scan payment methods: ", err.Error()) + return nil, &errr + } + + return paymentMethods, nil +} + +func scanGuardianPaymentMethod(row pgx.CollectableRow) (models.GuardianPaymentMethod, error) { + var pm models.GuardianPaymentMethod + + err := row.Scan( + &pm.ID, + &pm.GuardianID, + &pm.StripePaymentMethodID, + &pm.CardBrand, + &pm.CardLast4, + &pm.CardExpMonth, + &pm.CardExpYear, + &pm.IsDefault, + &pm.CreatedAt, + &pm.UpdatedAt, + ) + + return pm, err +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/get_by_guardian_id_test.go b/backend/internal/storage/postgres/schema/guardian-payment-method/get_by_guardian_id_test.go new file mode 100644 index 00000000..b4ba128f --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/get_by_guardian_id_test.go @@ -0,0 +1,161 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetPaymentMethodsByGuardianID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + cardBrand1 := "visa" + cardLast1 := "4242" + input1 := &models.CreateGuardianPaymentMethodInput{} + input1.Body.GuardianID = guardianID + input1.Body.StripePaymentMethodID = "pm_test_visa" + input1.Body.CardBrand = &cardBrand1 + input1.Body.CardLast4 = &cardLast1 + input1.Body.IsDefault = true + + created1, err := repo.CreateGuardianPaymentMethod(ctx, input1) + require.Nil(t, err) + + cardBrand2 := "mastercard" + cardLast2 := "5555" + input2 := &models.CreateGuardianPaymentMethodInput{} + input2.Body.GuardianID = guardianID + input2.Body.StripePaymentMethodID = "pm_test_mastercard" + input2.Body.CardBrand = &cardBrand2 + input2.Body.CardLast4 = &cardLast2 + input2.Body.IsDefault = false + + created2, err := repo.CreateGuardianPaymentMethod(ctx, input2) + require.Nil(t, err) + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + + require.Nil(t, err) + require.NotNil(t, paymentMethods) + assert.GreaterOrEqual(t, len(paymentMethods), 2) + + foundVisa := false + foundMastercard := false + for _, pm := range paymentMethods { + if pm.ID == created1.ID { + foundVisa = true + assert.Equal(t, "pm_test_visa", pm.StripePaymentMethodID) + assert.Equal(t, "visa", *pm.CardBrand) + assert.True(t, pm.IsDefault) + } + if pm.ID == created2.ID { + foundMastercard = true + assert.Equal(t, "pm_test_mastercard", pm.StripePaymentMethodID) + assert.Equal(t, "mastercard", *pm.CardBrand) + assert.False(t, pm.IsDefault) + } + } + assert.True(t, foundVisa) + assert.True(t, foundMastercard) +} + +func TestGetPaymentMethodsByGuardianID_Empty(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888889") + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + + require.Nil(t, err) + require.NotNil(t, paymentMethods) + assert.Equal(t, 0, len(paymentMethods)) +} + +func TestGetPaymentMethodsByGuardianID_DefaultFirst(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input1 := &models.CreateGuardianPaymentMethodInput{} + input1.Body.GuardianID = guardianID + input1.Body.StripePaymentMethodID = "pm_test_non_default" + input1.Body.IsDefault = false + repo.CreateGuardianPaymentMethod(ctx, input1) + + input2 := &models.CreateGuardianPaymentMethodInput{} + input2.Body.GuardianID = guardianID + input2.Body.StripePaymentMethodID = "pm_test_default" + input2.Body.IsDefault = true + repo.CreateGuardianPaymentMethod(ctx, input2) + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + + require.Nil(t, err) + assert.GreaterOrEqual(t, len(paymentMethods), 2) + + if len(paymentMethods) >= 2 { + assert.True(t, paymentMethods[0].IsDefault) + } +} + +func TestGetPaymentMethodsByGuardianID_OnlyReturnsOwnMethods(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardian1ID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + guardian2ID := uuid.MustParse("88888888-8888-8888-8888-888888888889") + + input1 := &models.CreateGuardianPaymentMethodInput{} + input1.Body.GuardianID = guardian1ID + input1.Body.StripePaymentMethodID = "pm_guardian1_card" + input1.Body.IsDefault = true + repo.CreateGuardianPaymentMethod(ctx, input1) + + input2 := &models.CreateGuardianPaymentMethodInput{} + input2.Body.GuardianID = guardian2ID + input2.Body.StripePaymentMethodID = "pm_guardian2_card" + input2.Body.IsDefault = true + repo.CreateGuardianPaymentMethod(ctx, input2) + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardian1ID) + + require.Nil(t, err) + for _, pm := range paymentMethods { + assert.Equal(t, guardian1ID, pm.GuardianID) + assert.NotEqual(t, "pm_guardian2_card", pm.StripePaymentMethodID) + } +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/repository.go b/backend/internal/storage/postgres/schema/guardian-payment-method/repository.go new file mode 100644 index 00000000..32f6a4cf --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/repository.go @@ -0,0 +1,11 @@ +package guardianpaymentmethod + +import "github.com/jackc/pgx/v5/pgxpool" + +type GuardianPaymentMethodRepository struct { + db *pgxpool.Pool +} + +func NewGuardianPaymentMethodRepository(db *pgxpool.Pool) *GuardianPaymentMethodRepository { + return &GuardianPaymentMethodRepository{db: db} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/sql/create.sql b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/create.sql new file mode 100644 index 00000000..866a0fe4 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/create.sql @@ -0,0 +1,21 @@ +INSERT INTO guardian_payment_methods ( + guardian_id, + stripe_payment_method_id, + card_brand, + card_last4, + card_exp_month, + card_exp_year, + is_default +) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING + id, + guardian_id, + stripe_payment_method_id, + card_brand, + card_last4, + card_exp_month, + card_exp_year, + is_default, + created_at, + updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/sql/delete.sql b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/delete.sql new file mode 100644 index 00000000..ad4d3108 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/delete.sql @@ -0,0 +1,13 @@ +DELETE FROM guardian_payment_methods +WHERE id = $1 +RETURNING + id, + guardian_id, + stripe_payment_method_id, + card_brand, + card_last4, + card_exp_month, + card_exp_year, + is_default, + created_at, + updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/sql/get_by_guardian_id.sql b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/get_by_guardian_id.sql new file mode 100644 index 00000000..1b7e946f --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/get_by_guardian_id.sql @@ -0,0 +1,14 @@ +SELECT + id, + guardian_id, + stripe_payment_method_id, + card_brand, + card_last4, + card_exp_month, + card_exp_year, + is_default, + created_at, + updated_at +FROM guardian_payment_methods +WHERE guardian_id = $1 +ORDER BY is_default DESC, created_at DESC; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/sql/update.sql b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/update.sql new file mode 100644 index 00000000..eed7a0a8 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/sql/update.sql @@ -0,0 +1,25 @@ +WITH unset_default AS ( + UPDATE guardian_payment_methods + SET is_default = false + WHERE guardian_id = ( + SELECT guardian_id FROM guardian_payment_methods WHERE id = $1 + ) + AND is_default = true + AND $2 = true +) +UPDATE guardian_payment_methods +SET + is_default = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING + id, + guardian_id, + stripe_payment_method_id, + card_brand, + card_last4, + card_exp_month, + card_exp_year, + is_default, + created_at, + updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/update.go b/backend/internal/storage/postgres/schema/guardian-payment-method/update.go new file mode 100644 index 00000000..ea08eb62 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/update.go @@ -0,0 +1,52 @@ +package guardianpaymentmethod + +import ( + "context" + "errors" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (r *GuardianPaymentMethodRepository) UpdateGuardianPaymentMethod( + ctx context.Context, + id uuid.UUID, + isDefault bool, +) (*models.GuardianPaymentMethod, error) { + query, err := schema.ReadSQLBaseScript("guardian-payment-method/sql/update.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, id, isDefault) + + var updatedPaymentMethod models.GuardianPaymentMethod + + err = row.Scan( + &updatedPaymentMethod.ID, + &updatedPaymentMethod.GuardianID, + &updatedPaymentMethod.StripePaymentMethodID, + &updatedPaymentMethod.CardBrand, + &updatedPaymentMethod.CardLast4, + &updatedPaymentMethod.CardExpMonth, + &updatedPaymentMethod.CardExpYear, + &updatedPaymentMethod.IsDefault, + &updatedPaymentMethod.CreatedAt, + &updatedPaymentMethod.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + errr := errs.NotFound("Guardian Payment Method", "id", id) + return nil, &errr + } + errr := errs.InternalServerError("Failed to update payment method: ", err.Error()) + return nil, &errr + } + + return &updatedPaymentMethod, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian-payment-method/update_test.go b/backend/internal/storage/postgres/schema/guardian-payment-method/update_test.go new file mode 100644 index 00000000..fc14c0e4 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian-payment-method/update_test.go @@ -0,0 +1,151 @@ +package guardianpaymentmethod + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateGuardianPaymentMethod_SetAsDefault(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input1 := &models.CreateGuardianPaymentMethodInput{} + input1.Body.GuardianID = guardianID + input1.Body.StripePaymentMethodID = "pm_test_old_default" + input1.Body.IsDefault = true + oldDefault, err := repo.CreateGuardianPaymentMethod(ctx, input1) + require.Nil(t, err) + require.True(t, oldDefault.IsDefault) + + input2 := &models.CreateGuardianPaymentMethodInput{} + input2.Body.GuardianID = guardianID + input2.Body.StripePaymentMethodID = "pm_test_new_default" + input2.Body.IsDefault = false + newCard, err := repo.CreateGuardianPaymentMethod(ctx, input2) + require.Nil(t, err) + require.False(t, newCard.IsDefault) + + updated, updateErr := repo.UpdateGuardianPaymentMethod(ctx, newCard.ID, true) + + require.Nil(t, updateErr) + require.NotNil(t, updated) + assert.True(t, updated.IsDefault) + assert.Equal(t, newCard.ID, updated.ID) + + oldDefaultCheck, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + require.Nil(t, err) + + defaultCount := 0 + for _, pm := range oldDefaultCheck { + if pm.IsDefault { + defaultCount++ + assert.Equal(t, newCard.ID, pm.ID) + } + } + assert.Equal(t, 1, defaultCount) +} + +func TestUpdateGuardianPaymentMethod_UnsetDefault(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input := &models.CreateGuardianPaymentMethodInput{} + input.Body.GuardianID = guardianID + input.Body.StripePaymentMethodID = "pm_test_unset" + input.Body.IsDefault = true + + created, createErr := repo.CreateGuardianPaymentMethod(ctx, input) + require.Nil(t, createErr) + require.True(t, created.IsDefault) + + updated, updateErr := repo.UpdateGuardianPaymentMethod(ctx, created.ID, false) + + require.Nil(t, updateErr) + require.NotNil(t, updated) + assert.False(t, updated.IsDefault) +} + +func TestUpdateGuardianPaymentMethod_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + updated, err := repo.UpdateGuardianPaymentMethod(ctx, uuid.New(), true) + + require.NotNil(t, err) + assert.Nil(t, updated) +} + +func TestUpdateGuardianPaymentMethod_OnlyOneDefault(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianPaymentMethodRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardianID := uuid.MustParse("88888888-8888-8888-8888-888888888888") + + input1 := &models.CreateGuardianPaymentMethodInput{} + input1.Body.GuardianID = guardianID + input1.Body.StripePaymentMethodID = "pm_test_card1" + input1.Body.IsDefault = true + repo.CreateGuardianPaymentMethod(ctx, input1) + + input2 := &models.CreateGuardianPaymentMethodInput{} + input2.Body.GuardianID = guardianID + input2.Body.StripePaymentMethodID = "pm_test_card2" + input2.Body.IsDefault = false + card2, _ := repo.CreateGuardianPaymentMethod(ctx, input2) + + input3 := &models.CreateGuardianPaymentMethodInput{} + input3.Body.GuardianID = guardianID + input3.Body.StripePaymentMethodID = "pm_test_card3" + input3.Body.IsDefault = false + card3, _ := repo.CreateGuardianPaymentMethod(ctx, input3) + + repo.UpdateGuardianPaymentMethod(ctx, card2.ID, true) + + repo.UpdateGuardianPaymentMethod(ctx, card3.ID, true) + + paymentMethods, err := repo.GetPaymentMethodsByGuardianID(ctx, guardianID) + require.Nil(t, err) + + defaultCount := 0 + for _, pm := range paymentMethods { + if pm.IsDefault { + defaultCount++ + assert.Equal(t, card3.ID, pm.ID) + } + } + assert.Equal(t, 1, defaultCount) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/create.go b/backend/internal/storage/postgres/schema/guardian/create.go index 7b023bc8..bf5017a5 100644 --- a/backend/internal/storage/postgres/schema/guardian/create.go +++ b/backend/internal/storage/postgres/schema/guardian/create.go @@ -19,11 +19,22 @@ func (r *GuardianRepository) CreateGuardian(ctx context.Context, guardian *model var createdGuardian models.Guardian - err = row.Scan(&createdGuardian.ID, &createdGuardian.UserID, &createdGuardian.Name, &createdGuardian.Email, &createdGuardian.Username, &createdGuardian.ProfilePictureS3Key, &createdGuardian.LanguagePreference, &createdGuardian.CreatedAt, &createdGuardian.UpdatedAt) + err = row.Scan( + &createdGuardian.ID, + &createdGuardian.UserID, + &createdGuardian.Name, + &createdGuardian.Email, + &createdGuardian.Username, + &createdGuardian.ProfilePictureS3Key, + &createdGuardian.LanguagePreference, + &createdGuardian.StripeCustomerID, + &createdGuardian.CreatedAt, + &createdGuardian.UpdatedAt, + ) if err != nil { err := errs.InternalServerError("Failed to create guardian: ", err.Error()) return nil, &err } return &createdGuardian, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/create_test.go b/backend/internal/storage/postgres/schema/guardian/create_test.go index 74845530..58a0d78f 100644 --- a/backend/internal/storage/postgres/schema/guardian/create_test.go +++ b/backend/internal/storage/postgres/schema/guardian/create_test.go @@ -39,8 +39,8 @@ func TestGuardianRepository_Create_David_Kim(t *testing.T) { assert.NotNil(t, guardian.CreatedAt) assert.NotNil(t, guardian.UpdatedAt) assert.Equal(t, guardianInput.Body.Name, guardian.Name) + assert.Nil(t, guardian.StripeCustomerID) - // Verify we can retrieve the created guardian retrievedGuardian, err := repo.GetGuardianByID(ctx, guardian.ID) if err != nil { t.Fatalf("Failed to retrieve guardian: %v", err) @@ -49,6 +49,7 @@ func TestGuardianRepository_Create_David_Kim(t *testing.T) { assert.NotNil(t, retrievedGuardian) assert.Equal(t, guardian.UserID, retrievedGuardian.UserID) assert.Equal(t, guardianInput.Body.Name, retrievedGuardian.Name) + assert.Nil(t, retrievedGuardian.StripeCustomerID) } func TestGuardianRepository_Create_Constraints(t *testing.T) { @@ -78,4 +79,4 @@ func TestGuardianRepository_Create_Constraints(t *testing.T) { assert.Error(t, err) assert.Nil(t, guardian) }) -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/delete.go b/backend/internal/storage/postgres/schema/guardian/delete.go index 2c88e729..6a11653e 100644 --- a/backend/internal/storage/postgres/schema/guardian/delete.go +++ b/backend/internal/storage/postgres/schema/guardian/delete.go @@ -22,7 +22,18 @@ func (r *GuardianRepository) DeleteGuardian(ctx context.Context, id uuid.UUID) ( var deletedGuardian models.Guardian - err = row.Scan(&deletedGuardian.ID, &deletedGuardian.UserID, &deletedGuardian.Name, &deletedGuardian.Email, &deletedGuardian.Username, &deletedGuardian.ProfilePictureS3Key, &deletedGuardian.LanguagePreference, &deletedGuardian.CreatedAt, &deletedGuardian.UpdatedAt) + err = row.Scan( + &deletedGuardian.ID, + &deletedGuardian.UserID, + &deletedGuardian.Name, + &deletedGuardian.Email, + &deletedGuardian.Username, + &deletedGuardian.ProfilePictureS3Key, + &deletedGuardian.LanguagePreference, + &deletedGuardian.StripeCustomerID, + &deletedGuardian.CreatedAt, + &deletedGuardian.UpdatedAt, + ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { err := errs.NotFound("Guardian", "id", id) @@ -33,4 +44,4 @@ func (r *GuardianRepository) DeleteGuardian(ctx context.Context, id uuid.UUID) ( } return &deletedGuardian, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/get_by_auth_id.go b/backend/internal/storage/postgres/schema/guardian/get_by_auth_id.go index 033ba9e8..144cae02 100644 --- a/backend/internal/storage/postgres/schema/guardian/get_by_auth_id.go +++ b/backend/internal/storage/postgres/schema/guardian/get_by_auth_id.go @@ -19,7 +19,13 @@ func (r *GuardianRepository) GetGuardianByAuthID(ctx context.Context, authID str row := r.db.QueryRow(ctx, query, authID) var guardian models.Guardian - err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.CreatedAt, &guardian.UpdatedAt) + err = row.Scan( + &guardian.ID, + &guardian.UserID, + &guardian.StripeCustomerID, + &guardian.CreatedAt, + &guardian.UpdatedAt, + ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -31,4 +37,4 @@ func (r *GuardianRepository) GetGuardianByAuthID(ctx context.Context, authID str } return &guardian, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/get_by_child_id.go b/backend/internal/storage/postgres/schema/guardian/get_by_child_id.go index 9b419591..68480adb 100644 --- a/backend/internal/storage/postgres/schema/guardian/get_by_child_id.go +++ b/backend/internal/storage/postgres/schema/guardian/get_by_child_id.go @@ -23,7 +23,7 @@ func (r *GuardianRepository) GetGuardianByChildID(ctx context.Context, childID u var guardian models.Guardian - err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.Name, &guardian.Email, &guardian.Username, &guardian.ProfilePictureS3Key, &guardian.LanguagePreference, &guardian.CreatedAt, &guardian.UpdatedAt) + err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.Name, &guardian.Email, &guardian.Username, &guardian.ProfilePictureS3Key, &guardian.LanguagePreference, &guardian.StripeCustomerID, &guardian.CreatedAt, &guardian.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { err := errs.BadRequest("Child with guardian id: " + childID.String() + " not found") diff --git a/backend/internal/storage/postgres/schema/guardian/get_by_id.go b/backend/internal/storage/postgres/schema/guardian/get_by_id.go index 1e07a7d3..efe311cd 100644 --- a/backend/internal/storage/postgres/schema/guardian/get_by_id.go +++ b/backend/internal/storage/postgres/schema/guardian/get_by_id.go @@ -23,7 +23,7 @@ func (r *GuardianRepository) GetGuardianByID(ctx context.Context, id uuid.UUID) var guardian models.Guardian - err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.Name, &guardian.Email, &guardian.Username, &guardian.ProfilePictureS3Key, &guardian.LanguagePreference, &guardian.CreatedAt, &guardian.UpdatedAt) + err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.Name, &guardian.Email, &guardian.Username, &guardian.ProfilePictureS3Key, &guardian.LanguagePreference, &guardian.StripeCustomerID, &guardian.CreatedAt, &guardian.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { err := errs.NotFound("Guardian", "id", id) diff --git a/backend/internal/storage/postgres/schema/guardian/get_by_user_id.go b/backend/internal/storage/postgres/schema/guardian/get_by_user_id.go index 7f981937..5601d358 100644 --- a/backend/internal/storage/postgres/schema/guardian/get_by_user_id.go +++ b/backend/internal/storage/postgres/schema/guardian/get_by_user_id.go @@ -22,7 +22,7 @@ func (r *GuardianRepository) GetGuardianByUserID(ctx context.Context, id uuid.UU var guardian models.Guardian - err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.Name, &guardian.Email, &guardian.Username, &guardian.ProfilePictureS3Key, &guardian.LanguagePreference, &guardian.CreatedAt, &guardian.UpdatedAt) + err = row.Scan(&guardian.ID, &guardian.UserID, &guardian.Name, &guardian.Email, &guardian.Username, &guardian.ProfilePictureS3Key, &guardian.LanguagePreference, &guardian.StripeCustomerID, &guardian.CreatedAt, &guardian.UpdatedAt) if err != nil { if errors.Is(err, pgx.ErrNoRows) { err := errs.NotFound("Guardian", "user_id", id) diff --git a/backend/internal/storage/postgres/schema/guardian/set_stripe_customer_id.go b/backend/internal/storage/postgres/schema/guardian/set_stripe_customer_id.go new file mode 100644 index 00000000..41119959 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian/set_stripe_customer_id.go @@ -0,0 +1,47 @@ +package guardian + +import ( + "context" + "errors" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (r *GuardianRepository) SetStripeCustomerID( + ctx context.Context, + guardianID uuid.UUID, + stripeCustomerID string, +) (*models.Guardian, error) { + query, err := schema.ReadSQLBaseScript("guardian/sql/set_stripe_customer_id.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, guardianID, stripeCustomerID) + + var updatedGuardian models.Guardian + + err = row.Scan( + &updatedGuardian.ID, + &updatedGuardian.UserID, + &updatedGuardian.StripeCustomerID, + &updatedGuardian.CreatedAt, + &updatedGuardian.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + errr := errs.NotFound("Guardian", "id", guardianID) + return nil, &errr + } + errr := errs.InternalServerError("Failed to set stripe customer ID: ", err.Error()) + return nil, &errr + } + + return &updatedGuardian, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/set_stripe_customer_id_test.go b/backend/internal/storage/postgres/schema/guardian/set_stripe_customer_id_test.go new file mode 100644 index 00000000..6a872015 --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian/set_stripe_customer_id_test.go @@ -0,0 +1,115 @@ +package guardian + +import ( + "context" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetStripeCustomerID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianRepository(testDB) + ctx := context.Background() + t.Parallel() + + testGuardian := CreateTestGuardian(t, ctx, testDB) + require.NotNil(t, testGuardian) + require.Nil(t, testGuardian.StripeCustomerID) + + stripeCustomerID := "cus_test123abc" + updated, err := repo.SetStripeCustomerID(ctx, testGuardian.ID, stripeCustomerID) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, testGuardian.ID, updated.ID) + assert.NotNil(t, updated.StripeCustomerID) + assert.Equal(t, stripeCustomerID, *updated.StripeCustomerID) + + fetched, getErr := repo.GetGuardianByID(ctx, testGuardian.ID) + require.Nil(t, getErr) + assert.Equal(t, stripeCustomerID, *fetched.StripeCustomerID) +} + +func TestSetStripeCustomerID_UpdateExisting(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianRepository(testDB) + ctx := context.Background() + t.Parallel() + + testGuardian := CreateTestGuardian(t, ctx, testDB) + + firstCustomerID := "cus_first123" + updated1, err := repo.SetStripeCustomerID(ctx, testGuardian.ID, firstCustomerID) + require.Nil(t, err) + assert.Equal(t, firstCustomerID, *updated1.StripeCustomerID) + + secondCustomerID := "cus_second456" + updated2, err := repo.SetStripeCustomerID(ctx, testGuardian.ID, secondCustomerID) + require.Nil(t, err) + assert.Equal(t, secondCustomerID, *updated2.StripeCustomerID) +} + +func TestSetStripeCustomerID_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianRepository(testDB) + ctx := context.Background() + t.Parallel() + + nonExistentID := uuid.New() + stripeCustomerID := "cus_test123" + + updated, err := repo.SetStripeCustomerID(ctx, nonExistentID, stripeCustomerID) + + require.NotNil(t, err) + assert.Nil(t, updated) +} + +func TestSetStripeCustomerID_DeletesPaymentMethods(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + guardianRepo := NewGuardianRepository(testDB) + ctx := context.Background() + t.Parallel() + + testGuardian := CreateTestGuardian(t, ctx, testDB) + firstCustomerID := "cus_first123" + guardianRepo.SetStripeCustomerID(ctx, testGuardian.ID, firstCustomerID) + + _, err := testDB.Exec(ctx, ` + INSERT INTO guardian_payment_methods (guardian_id, stripe_payment_method_id, is_default) + VALUES ($1, 'pm_old_card', true) + `, testGuardian.ID) + require.Nil(t, err) + + var countBefore int + testDB.QueryRow(ctx, `SELECT COUNT(*) FROM guardian_payment_methods WHERE guardian_id = $1`, testGuardian.ID).Scan(&countBefore) + assert.Equal(t, 1, countBefore) + + secondCustomerID := "cus_second456" + updated, err := guardianRepo.SetStripeCustomerID(ctx, testGuardian.ID, secondCustomerID) + require.Nil(t, err) + assert.Equal(t, secondCustomerID, *updated.StripeCustomerID) + + var countAfter int + testDB.QueryRow(ctx, `SELECT COUNT(*) FROM guardian_payment_methods WHERE guardian_id = $1`, testGuardian.ID).Scan(&countAfter) + assert.Equal(t, 0, countAfter) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/create.sql b/backend/internal/storage/postgres/schema/guardian/sql/create.sql index 279a968c..76422555 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/create.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/create.sql @@ -6,8 +6,8 @@ WITH new_user AS ( new_guardian AS ( INSERT INTO guardian (user_id) SELECT id FROM new_user - RETURNING id, user_id, created_at, updated_at + RETURNING id, user_id, stripe_customer_id, created_at, updated_at ) -SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.created_at, g.updated_at +SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.stripe_customer_id, g.created_at, g.updated_at FROM new_guardian g JOIN new_user u ON g.user_id = u.id; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/delete.sql b/backend/internal/storage/postgres/schema/guardian/sql/delete.sql index 7fd5354a..6f861012 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/delete.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/delete.sql @@ -1,12 +1,12 @@ WITH deleted_guardian AS ( DELETE FROM guardian WHERE id = $1 - RETURNING id, user_id, created_at, updated_at + RETURNING id, user_id, stripe_customer_id, created_at, updated_at ), deleted_user AS ( DELETE FROM "user" WHERE id = (SELECT user_id FROM deleted_guardian) RETURNING id, name, email, username, profile_picture_s3_key, language_preference ) -SELECT dg.id, dg.user_id, du.name, du.email, du.username, du.profile_picture_s3_key, du.language_preference, dg.created_at, dg.updated_at +SELECT dg.id, dg.user_id, du.name, du.email, du.username, du.profile_picture_s3_key, du.language_preference, dg.stripe_customer_id, dg.created_at, dg.updated_at FROM deleted_guardian dg JOIN deleted_user du ON dg.user_id = du.id; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/get_by_auth_id.sql b/backend/internal/storage/postgres/schema/guardian/sql/get_by_auth_id.sql index da65c6e1..84fc2c5e 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/get_by_auth_id.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/get_by_auth_id.sql @@ -1,4 +1,4 @@ -SELECT g.id, g.user_id, g.created_at, g.updated_at +SELECT g.id, g.user_id, g.stripe_customer_id, g.created_at, g.updated_at FROM guardian g INNER JOIN "user" u ON g.user_id = u.id -WHERE u.auth_id = $1; +WHERE u.auth_id = $1; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/get_by_child_id.sql b/backend/internal/storage/postgres/schema/guardian/sql/get_by_child_id.sql index 090548fe..065b8f39 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/get_by_child_id.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/get_by_child_id.sql @@ -1,4 +1,4 @@ -SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.created_at, g.updated_at +SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.stripe_customer_id, g.created_at, g.updated_at FROM guardian g JOIN "user" u ON g.user_id = u.id INNER JOIN child c ON c.guardian_id = g.id diff --git a/backend/internal/storage/postgres/schema/guardian/sql/get_by_id.sql b/backend/internal/storage/postgres/schema/guardian/sql/get_by_id.sql index 9113a8fe..edf69ad5 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/get_by_id.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/get_by_id.sql @@ -1,4 +1,4 @@ -SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.created_at, g.updated_at +SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.stripe_customer_id, g.created_at, g.updated_at FROM guardian g JOIN "user" u ON g.user_id = u.id WHERE g.id = $1 \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/get_by_user_id.sql b/backend/internal/storage/postgres/schema/guardian/sql/get_by_user_id.sql index eee16d4d..5eec0799 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/get_by_user_id.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/get_by_user_id.sql @@ -1,4 +1,4 @@ -SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.created_at, g.updated_at +SELECT g.id, g.user_id, u.name, u.email, u.username, u.profile_picture_s3_key, u.language_preference, g.stripe_customer_id, g.created_at, g.updated_at FROM guardian g JOIN "user" u ON g.user_id = u.id WHERE g.user_id = $1; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/set_stripe_customer_id.sql b/backend/internal/storage/postgres/schema/guardian/sql/set_stripe_customer_id.sql new file mode 100644 index 00000000..e186924e --- /dev/null +++ b/backend/internal/storage/postgres/schema/guardian/sql/set_stripe_customer_id.sql @@ -0,0 +1,10 @@ +WITH delete_payment_methods AS ( + DELETE FROM guardian_payment_methods + WHERE guardian_id = $1 +) +UPDATE guardian +SET + stripe_customer_id = $2, + updated_at = NOW() +WHERE id = $1 +RETURNING id, user_id, stripe_customer_id, created_at, updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/sql/update_guardian.sql b/backend/internal/storage/postgres/schema/guardian/sql/update_guardian.sql index 9bea635b..a0cadc38 100644 --- a/backend/internal/storage/postgres/schema/guardian/sql/update_guardian.sql +++ b/backend/internal/storage/postgres/schema/guardian/sql/update_guardian.sql @@ -1,4 +1,4 @@ UPDATE guardian SET updated_at = NOW() WHERE id = $1 -RETURNING user_id, created_at, updated_at; \ No newline at end of file +RETURNING user_id, stripe_customer_id, created_at, updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/update.go b/backend/internal/storage/postgres/schema/guardian/update.go index e79053b5..2eb1ebc1 100644 --- a/backend/internal/storage/postgres/schema/guardian/update.go +++ b/backend/internal/storage/postgres/schema/guardian/update.go @@ -26,6 +26,7 @@ func (r *GuardianRepository) UpdateGuardian(ctx context.Context, guardian *model err = tx.QueryRow(ctx, guardianQuery, guardian.ID).Scan( &updatedGuardian.UserID, + &updatedGuardian.StripeCustomerID, &updatedGuardian.CreatedAt, &updatedGuardian.UpdatedAt, ) @@ -69,4 +70,4 @@ func (r *GuardianRepository) UpdateGuardian(ctx context.Context, guardian *model } return &updatedGuardian, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/guardian/update_test.go b/backend/internal/storage/postgres/schema/guardian/update_test.go index 9278bf7e..20abe7d3 100644 --- a/backend/internal/storage/postgres/schema/guardian/update_test.go +++ b/backend/internal/storage/postgres/schema/guardian/update_test.go @@ -43,7 +43,6 @@ func TestGuardianRepository_Update_David_Kim(t *testing.T) { assert.NotNil(t, guardian.UpdatedAt) assert.Equal(t, guardianInput.ID, guardian.ID) - // Verify we can retrieve the updated guardian retrievedGuardian, err := repo.GetGuardianByID(ctx, guardianInput.ID) if err != nil { t.Fatalf("Failed to retrieve guardian: %v", err) @@ -74,3 +73,29 @@ func TestGuardianRepository_Update_Errors(t *testing.T) { assert.Nil(t, guardian) }) } + +func TestGuardianRepository_Update_DoesNotModifyStripeCustomerID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewGuardianRepository(testDB) + ctx := context.Background() + t.Parallel() + + guardian := CreateTestGuardian(t, ctx, testDB) + stripeCustomerID := "cus_test123" + + repo.SetStripeCustomerID(ctx, guardian.ID, stripeCustomerID) + + updateInput := &models.UpdateGuardianInput{} + updateInput.ID = guardian.ID + updateInput.Body.Name = "Updated Name" + + updated, err := repo.UpdateGuardian(ctx, updateInput) + + assert.Nil(t, err) + assert.Equal(t, "Updated Name", updated.Name) + assert.Equal(t, stripeCustomerID, *updated.StripeCustomerID) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/location/get_by_organizatiojn_id_test.go b/backend/internal/storage/postgres/schema/location/get_by_organizatiojn_id_test.go new file mode 100644 index 00000000..2ddb48fc --- /dev/null +++ b/backend/internal/storage/postgres/schema/location/get_by_organizatiojn_id_test.go @@ -0,0 +1,70 @@ +package location + +import ( + "context" + "testing" + + "skillspark/internal/storage/postgres/testutil" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func TestLocationRepository_GetLocationByOrganizationID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database test in short mode") + } + + testDB := testutil.SetupTestDB(t) + repo := NewLocationRepository(testDB) + ctx := context.Background() + + // get location for Science Academy Bangkok + location, err := repo.GetLocationByOrganizationID(ctx, uuid.MustParse("40000000-0000-0000-0000-000000000001")) + if err != nil { + t.Fatalf("Failed to get location by organization id: %v", err) + } + + assert.NotNil(t, location) + assert.Equal(t, uuid.MustParse("10000000-0000-0000-0000-000000000004"), location.ID) + assert.Equal(t, "10400", location.PostalCode) + assert.Equal(t, "Thailand", location.Country) + assert.Equal(t, "321 Phetchaburi Road", location.AddressLine1) + assert.Equal(t, "Suite 15", *location.AddressLine2) + assert.Equal(t, "Ratchathewi", location.District) + assert.NotNil(t, location.CreatedAt) + assert.NotNil(t, location.UpdatedAt) + + // get location for Champions Sports Center + location2, err := repo.GetLocationByOrganizationID(ctx, uuid.MustParse("40000000-0000-0000-0000-000000000002")) + if err != nil { + t.Fatalf("Failed to get location by organization id: %v", err) + } + + assert.NotNil(t, location2) + assert.Equal(t, uuid.MustParse("10000000-0000-0000-0000-000000000005"), location2.ID) + assert.Equal(t, "10120", location2.PostalCode) + assert.Equal(t, "Thailand", location2.Country) + assert.Equal(t, "654 Sathorn Road", location2.AddressLine1) + assert.Nil(t, location2.AddressLine2) + assert.NotNil(t, location2.CreatedAt) + assert.NotNil(t, location2.UpdatedAt) + + // get location for Creative Arts Studio + location3, err := repo.GetLocationByOrganizationID(ctx, uuid.MustParse("40000000-0000-0000-0000-000000000003")) + if err != nil { + t.Fatalf("Failed to get location by organization id: %v", err) + } + + assert.NotNil(t, location3) + assert.Equal(t, uuid.MustParse("10000000-0000-0000-0000-000000000006"), location3.ID) + assert.Equal(t, "10230", location3.PostalCode) + assert.Equal(t, "Thailand", location3.Country) + assert.Equal(t, "147 Lat Phrao Road", location3.AddressLine1) + assert.Equal(t, "Building C", *location3.AddressLine2) + + // get location for organization that does not exist + location4, err := repo.GetLocationByOrganizationID(ctx, uuid.MustParse("40000000-0000-0000-0000-000000000099")) + assert.Nil(t, location4) + assert.NotNil(t, err) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/location/get_by_organization_id.go b/backend/internal/storage/postgres/schema/location/get_by_organization_id.go new file mode 100644 index 00000000..d3ccaf06 --- /dev/null +++ b/backend/internal/storage/postgres/schema/location/get_by_organization_id.go @@ -0,0 +1,48 @@ +package location + +import ( + "context" + "errors" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +func (r *LocationRepository) GetLocationByOrganizationID(ctx context.Context, orgID uuid.UUID) (*models.Location, error) { + query, err := schema.ReadSQLBaseScript("location/sql/get_by_organization_id.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, orgID) + var location models.Location + err = row.Scan( + &location.ID, + &location.Latitude, + &location.Longitude, + &location.AddressLine1, + &location.AddressLine2, + &location.Subdistrict, + &location.District, + &location.Province, + &location.PostalCode, + &location.Country, + &location.CreatedAt, + &location.UpdatedAt, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + err := errs.NotFound("Location", "organization_id", orgID) + return nil, &err + } + err := errs.InternalServerError("Failed to fetch location by organization id: ", err.Error()) + return nil, &err + } + + return &location, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/location/sql/get_by_organization_id.sql b/backend/internal/storage/postgres/schema/location/sql/get_by_organization_id.sql new file mode 100644 index 00000000..54b2a432 --- /dev/null +++ b/backend/internal/storage/postgres/schema/location/sql/get_by_organization_id.sql @@ -0,0 +1,16 @@ +SELECT + l.id, + l.latitude, + l.longitude, + l.address_line1, + l.address_line2, + l.subdistrict, + l.district, + l.province, + l.postal_code, + l.country, + l.created_at, + l.updated_at +FROM location l +INNER JOIN organization o ON o.location_id = l.id +WHERE o.id = $1; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/create.go b/backend/internal/storage/postgres/schema/organization/create.go index fddd0d0c..391e1b18 100644 --- a/backend/internal/storage/postgres/schema/organization/create.go +++ b/backend/internal/storage/postgres/schema/organization/create.go @@ -30,6 +30,8 @@ func (r *OrganizationRepository) CreateOrganization(ctx context.Context, input * &createdOrganization.Active, &createdOrganization.PfpS3Key, &createdOrganization.LocationID, + &createdOrganization.StripeAccountID, + &createdOrganization.StripeAccountActivated, &createdOrganization.CreatedAt, &createdOrganization.UpdatedAt, ) diff --git a/backend/internal/storage/postgres/schema/organization/create_test.go b/backend/internal/storage/postgres/schema/organization/create_test.go index 43dd0975..2ca91644 100644 --- a/backend/internal/storage/postgres/schema/organization/create_test.go +++ b/backend/internal/storage/postgres/schema/organization/create_test.go @@ -33,6 +33,10 @@ func TestCreateOrganization(t *testing.T) { assert.Equal(t, "Test Corp", created.Name) assert.True(t, created.Active) assert.NotEqual(t, uuid.Nil, created.ID) + + // Verify Stripe fields default correctly + assert.Nil(t, created.StripeAccountID) + assert.False(t, created.StripeAccountActivated) } func TestCreateOrganization_WithLocation(t *testing.T) { @@ -58,6 +62,8 @@ func TestCreateOrganization_WithLocation(t *testing.T) { assert.Equal(t, "Test Corp with Location", created.Name) assert.True(t, created.Active) assert.Equal(t, &locationID, created.LocationID) + assert.Nil(t, created.StripeAccountID) + assert.False(t, created.StripeAccountActivated) } func TestCreateOrganization_WithPfp(t *testing.T) { @@ -81,6 +87,8 @@ func TestCreateOrganization_WithPfp(t *testing.T) { require.NotNil(t, created) assert.Equal(t, "Test Corp with Profile", created.Name) assert.Equal(t, &pfpKey, created.PfpS3Key) + assert.Nil(t, created.StripeAccountID) + assert.False(t, created.StripeAccountActivated) } func TestCreateOrganization_Inactive(t *testing.T) { @@ -103,6 +111,8 @@ func TestCreateOrganization_Inactive(t *testing.T) { require.NotNil(t, created) assert.Equal(t, "Inactive Corp", created.Name) assert.False(t, created.Active) + assert.Nil(t, created.StripeAccountID) + assert.False(t, created.StripeAccountActivated) } func TestCreateOrganization_FullDetails(t *testing.T) { @@ -130,4 +140,6 @@ func TestCreateOrganization_FullDetails(t *testing.T) { assert.True(t, created.Active) assert.Equal(t, &pfpKey, created.PfpS3Key) assert.Equal(t, &locationID, created.LocationID) -} + assert.Nil(t, created.StripeAccountID) + assert.False(t, created.StripeAccountActivated) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/delete.go b/backend/internal/storage/postgres/schema/organization/delete.go index 38a648bc..d27c3059 100644 --- a/backend/internal/storage/postgres/schema/organization/delete.go +++ b/backend/internal/storage/postgres/schema/organization/delete.go @@ -26,6 +26,8 @@ func (r *OrganizationRepository) DeleteOrganization(ctx context.Context, id uuid &deletedOrganization.Active, &deletedOrganization.PfpS3Key, &deletedOrganization.LocationID, + &deletedOrganization.StripeAccountID, + &deletedOrganization.StripeAccountActivated, &deletedOrganization.CreatedAt, &deletedOrganization.UpdatedAt, ) diff --git a/backend/internal/storage/postgres/schema/organization/delete_test.go b/backend/internal/storage/postgres/schema/organization/delete_test.go index c8d7d4aa..42e43470 100644 --- a/backend/internal/storage/postgres/schema/organization/delete_test.go +++ b/backend/internal/storage/postgres/schema/organization/delete_test.go @@ -17,7 +17,6 @@ func TestDeleteOrganization(t *testing.T) { ctx := context.Background() t.Parallel() - // Create an organization to delete active := true input := func() *models.CreateOrganizationInput { i := &models.CreateOrganizationInput{} @@ -30,14 +29,14 @@ func TestDeleteOrganization(t *testing.T) { require.Nil(t, createErr) require.NotNil(t, created) - // Delete the organization deleted, deleteErr := repo.DeleteOrganization(ctx, created.ID) require.Nil(t, deleteErr) require.NotNil(t, deleted) assert.Equal(t, created.ID, deleted.ID) assert.Equal(t, "To Be Deleted", deleted.Name) + assert.Nil(t, deleted.StripeAccountID) + assert.False(t, deleted.StripeAccountActivated) - // Verify it's gone _, getErr := repo.GetOrganizationByID(ctx, created.ID) assert.NotNil(t, getErr) } @@ -48,7 +47,6 @@ func TestDeleteOrganization_NotFound(t *testing.T) { ctx := context.Background() t.Parallel() - // Try to delete non-existent organization deleted, err := repo.DeleteOrganization(ctx, uuid.New()) require.NotNil(t, err) @@ -61,7 +59,6 @@ func TestDeleteOrganization_AlreadyDeleted(t *testing.T) { ctx := context.Background() t.Parallel() - // Create organization active := true input := func() *models.CreateOrganizationInput { i := &models.CreateOrganizationInput{} @@ -74,13 +71,31 @@ func TestDeleteOrganization_AlreadyDeleted(t *testing.T) { require.Nil(t, createErr) require.NotNil(t, created) - // First delete should succeed deleted1, deleteErr1 := repo.DeleteOrganization(ctx, created.ID) require.Nil(t, deleteErr1) require.NotNil(t, deleted1) - // Second delete should fail deleted2, deleteErr2 := repo.DeleteOrganization(ctx, created.ID) require.NotNil(t, deleteErr2) assert.Nil(t, deleted2) } + +func TestDeleteOrganization_WithStripeAccount(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + stripeAccountID := "acct_delete_test123" + + repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + repo.SetStripeAccountActivated(ctx, stripeAccountID, true) + + deleted, err := repo.DeleteOrganization(ctx, testOrg.ID) + + require.Nil(t, err) + require.NotNil(t, deleted) + assert.Equal(t, stripeAccountID, *deleted.StripeAccountID) + assert.True(t, deleted.StripeAccountActivated) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/get_all.go b/backend/internal/storage/postgres/schema/organization/get_all.go index 9fc8c1f5..27b65e6a 100644 --- a/backend/internal/storage/postgres/schema/organization/get_all.go +++ b/backend/internal/storage/postgres/schema/organization/get_all.go @@ -42,6 +42,8 @@ func scanOrganization(row pgx.CollectableRow) (models.Organization, error) { &org.Active, &org.PfpS3Key, &org.LocationID, + &org.StripeAccountID, + &org.StripeAccountActivated, &org.CreatedAt, &org.UpdatedAt, ) diff --git a/backend/internal/storage/postgres/schema/organization/get_all_test.go b/backend/internal/storage/postgres/schema/organization/get_all_test.go index 5c22cbe7..06b085ce 100644 --- a/backend/internal/storage/postgres/schema/organization/get_all_test.go +++ b/backend/internal/storage/postgres/schema/organization/get_all_test.go @@ -23,6 +23,11 @@ func TestGetAllOrganizations_BasicPagination(t *testing.T) { require.NotNil(t, orgs) assert.GreaterOrEqual(t, len(orgs), 3) assert.LessOrEqual(t, len(orgs), 10) + + if len(orgs) > 0 { + assert.Nil(t, orgs[0].StripeAccountID) + assert.False(t, orgs[0].StripeAccountActivated) + } } func TestGetAllOrganizations_SecondPage(t *testing.T) { @@ -45,6 +50,7 @@ func TestGetAllOrganizations_SecondPage(t *testing.T) { if len(secondPageOrgs) > 0 && len(firstPageOrgs) > 0 { assert.NotEqual(t, firstPageOrgs[0].ID, secondPageOrgs[0].ID) + assert.False(t, secondPageOrgs[0].StripeAccountActivated) } } @@ -52,7 +58,6 @@ func TestGetAllOrganizations_SmallPageSize(t *testing.T) { testDB := testutil.SetupTestDB(t) repo := NewOrganizationRepository(testDB) ctx := context.Background() - t.Parallel() pagination := utils.Pagination{Page: 1, Limit: 2} @@ -61,6 +66,10 @@ func TestGetAllOrganizations_SmallPageSize(t *testing.T) { require.Nil(t, err) require.NotNil(t, orgs) assert.Equal(t, 2, len(orgs)) + + for _, org := range orgs { + assert.False(t, org.StripeAccountActivated) + } } func TestGetAllOrganizations_SingleItemPerPage(t *testing.T) { @@ -75,6 +84,9 @@ func TestGetAllOrganizations_SingleItemPerPage(t *testing.T) { require.Nil(t, err) require.NotNil(t, orgs) assert.Equal(t, 1, len(orgs)) + + assert.Nil(t, orgs[0].StripeAccountID) + assert.False(t, orgs[0].StripeAccountActivated) } func TestGetAllOrganizations_PageBeyondData(t *testing.T) { @@ -103,6 +115,10 @@ func TestGetAllOrganizations_AllDataOnePage(t *testing.T) { require.Nil(t, err) require.NotNil(t, orgs) assert.GreaterOrEqual(t, len(orgs), 3) + + for _, org := range orgs { + assert.False(t, org.StripeAccountActivated) + } } func TestGetAllOrganizations_OrderByCreatedAt(t *testing.T) { @@ -122,6 +138,7 @@ func TestGetAllOrganizations_OrderByCreatedAt(t *testing.T) { orgs[i].CreatedAt.After(orgs[i+1].CreatedAt) || orgs[i].CreatedAt.Equal(orgs[i+1].CreatedAt), "Organizations should be ordered by created_at DESC", ) + assert.False(t, orgs[i].StripeAccountActivated) } } @@ -137,4 +154,9 @@ func TestGetAllOrganizations_ZeroOffset(t *testing.T) { require.Nil(t, err) require.NotNil(t, orgs) assert.Equal(t, 3, len(orgs)) -} + + for _, org := range orgs { + assert.Nil(t, org.StripeAccountID) + assert.False(t, org.StripeAccountActivated) + } +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/get_by_id.go b/backend/internal/storage/postgres/schema/organization/get_by_id.go index 0451be70..c7428ab9 100644 --- a/backend/internal/storage/postgres/schema/organization/get_by_id.go +++ b/backend/internal/storage/postgres/schema/organization/get_by_id.go @@ -26,6 +26,8 @@ func (r *OrganizationRepository) GetOrganizationByID(ctx context.Context, id uui &org.Active, &org.PfpS3Key, &org.LocationID, + &org.StripeAccountID, + &org.StripeAccountActivated, &org.CreatedAt, &org.UpdatedAt, ) diff --git a/backend/internal/storage/postgres/schema/organization/get_by_id_test.go b/backend/internal/storage/postgres/schema/organization/get_by_id_test.go index 4e5cfb80..df7988ee 100644 --- a/backend/internal/storage/postgres/schema/organization/get_by_id_test.go +++ b/backend/internal/storage/postgres/schema/organization/get_by_id_test.go @@ -21,8 +21,12 @@ func TestGetById(t *testing.T) { org, err := repo.GetOrganizationByID(ctx, testorg.ID) require.Nil(t, err) + require.NotNil(t, org) assert.Equal(t, "Test Corp", org.Name) assert.True(t, org.Active) + + assert.Nil(t, org.StripeAccountID) + assert.False(t, org.StripeAccountActivated) } func TestExecute_NotFound(t *testing.T) { @@ -35,4 +39,4 @@ func TestExecute_NotFound(t *testing.T) { require.Error(t, err) assert.Nil(t, org) -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/set_stripe_account_activated.go b/backend/internal/storage/postgres/schema/organization/set_stripe_account_activated.go new file mode 100644 index 00000000..44d403b3 --- /dev/null +++ b/backend/internal/storage/postgres/schema/organization/set_stripe_account_activated.go @@ -0,0 +1,41 @@ +package organization + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" +) + +func (r *OrganizationRepository) SetStripeAccountActivated(ctx context.Context, stripeAccountID string, activated bool) (*models.Organization, error) { + query, err := schema.ReadSQLBaseScript("organization/sql/set_stripe_account_activated.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, activated, stripeAccountID) + + var updatedOrganization models.Organization + err = row.Scan( + &updatedOrganization.ID, + &updatedOrganization.Name, + &updatedOrganization.Active, + &updatedOrganization.PfpS3Key, + &updatedOrganization.LocationID, + &updatedOrganization.StripeAccountID, + &updatedOrganization.StripeAccountActivated, + &updatedOrganization.CreatedAt, + &updatedOrganization.UpdatedAt, + ) + if err != nil { + if err.Error() == "no rows in result set" { + errr := errs.NotFound("Organization", "stripe_account_id", stripeAccountID) + return nil, &errr + } + errr := errs.InternalServerError("Failed to update stripe activation: ", err.Error()) + return nil, &errr + } + + return &updatedOrganization, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/set_stripe_account_activated_test.go b/backend/internal/storage/postgres/schema/organization/set_stripe_account_activated_test.go new file mode 100644 index 00000000..2333f047 --- /dev/null +++ b/backend/internal/storage/postgres/schema/organization/set_stripe_account_activated_test.go @@ -0,0 +1,115 @@ +package organization + +import ( + "context" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetStripeAccountActivated(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + stripeAccountID := "acct_1SwvOB2Sjs4wsi8o" + + orgWithAccount, err := repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + require.Nil(t, err) + require.False(t, orgWithAccount.StripeAccountActivated) + + updated, err := repo.SetStripeAccountActivated(ctx, stripeAccountID, true) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, testOrg.ID, updated.ID) + assert.Equal(t, stripeAccountID, *updated.StripeAccountID) + assert.True(t, updated.StripeAccountActivated) + + fetched, getErr := repo.GetOrganizationByID(ctx, testOrg.ID) + require.Nil(t, getErr) + assert.True(t, fetched.StripeAccountActivated) +} + +func TestSetStripeAccountActivated_Deactivate(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + stripeAccountID := "acct_deactivate123" + + repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + repo.SetStripeAccountActivated(ctx, stripeAccountID, true) + + deactivated, err := repo.SetStripeAccountActivated(ctx, stripeAccountID, false) + + require.Nil(t, err) + require.NotNil(t, deactivated) + assert.False(t, deactivated.StripeAccountActivated) +} + +func TestSetStripeAccountActivated_NotFound(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + nonExistentAccountID := "acct_doesnotexist123" + + updated, err := repo.SetStripeAccountActivated(ctx, nonExistentAccountID, true) + + require.NotNil(t, err) + assert.Nil(t, updated) +} + +func TestSetStripeAccountActivated_DoesNotModifyOtherFields(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + stripeAccountID := "acct_fieldstest123" + + orgWithAccount, _ := repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + originalName := orgWithAccount.Name + originalActive := orgWithAccount.Active + + updated, err := repo.SetStripeAccountActivated(ctx, stripeAccountID, true) + + require.Nil(t, err) + assert.Equal(t, originalName, updated.Name) + assert.Equal(t, originalActive, updated.Active) + assert.Equal(t, stripeAccountID, *updated.StripeAccountID) + assert.True(t, updated.StripeAccountActivated) +} + +func TestSetStripeAccountActivated_MultipleToggle(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + stripeAccountID := "acct_toggle123" + + repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + + activated, err := repo.SetStripeAccountActivated(ctx, stripeAccountID, true) + require.Nil(t, err) + assert.True(t, activated.StripeAccountActivated) + + deactivated, err := repo.SetStripeAccountActivated(ctx, stripeAccountID, false) + require.Nil(t, err) + assert.False(t, deactivated.StripeAccountActivated) + + reactivated, err := repo.SetStripeAccountActivated(ctx, stripeAccountID, true) + require.Nil(t, err) + assert.True(t, reactivated.StripeAccountActivated) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/set_stripe_account_id.go b/backend/internal/storage/postgres/schema/organization/set_stripe_account_id.go new file mode 100644 index 00000000..0a749c6a --- /dev/null +++ b/backend/internal/storage/postgres/schema/organization/set_stripe_account_id.go @@ -0,0 +1,43 @@ +package organization + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/google/uuid" +) + +func (r *OrganizationRepository) SetStripeAccountID(ctx context.Context, orgID uuid.UUID, stripeAccountID string) (*models.Organization, error) { + query, err := schema.ReadSQLBaseScript("organization/sql/set_stripe_account_id.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, stripeAccountID, orgID) + + var updatedOrganization models.Organization + err = row.Scan( + &updatedOrganization.ID, + &updatedOrganization.Name, + &updatedOrganization.Active, + &updatedOrganization.PfpS3Key, + &updatedOrganization.LocationID, + &updatedOrganization.StripeAccountID, + &updatedOrganization.StripeAccountActivated, + &updatedOrganization.CreatedAt, + &updatedOrganization.UpdatedAt, + ) + if err != nil { + if err.Error() == "no rows in result set" { + errr := errs.NotFound("Organization", "id", orgID.String()) + return nil, &errr + } + errr := errs.InternalServerError("Failed to set stripe account: ", err.Error()) + return nil, &errr + } + + return &updatedOrganization, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/set_stripe_account_id_test.go b/backend/internal/storage/postgres/schema/organization/set_stripe_account_id_test.go new file mode 100644 index 00000000..32ebc642 --- /dev/null +++ b/backend/internal/storage/postgres/schema/organization/set_stripe_account_id_test.go @@ -0,0 +1,92 @@ +package organization + +import ( + "context" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetStripeAccountID(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + require.NotNil(t, testOrg) + require.Nil(t, testOrg.StripeAccountID) + + stripeAccountID := "acct_1SwvOB2Sjs4wsi8o" + updated, err := repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, testOrg.ID, updated.ID) + assert.NotNil(t, updated.StripeAccountID) + assert.Equal(t, stripeAccountID, *updated.StripeAccountID) + assert.False(t, updated.StripeAccountActivated) + + fetched, getErr := repo.GetOrganizationByID(ctx, testOrg.ID) + require.Nil(t, getErr) + assert.Equal(t, stripeAccountID, *fetched.StripeAccountID) + assert.False(t, fetched.StripeAccountActivated) +} + +func TestSetStripeAccountID_MultipleTimes(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + + firstAccountID := "acct_first123" + updated1, err := repo.SetStripeAccountID(ctx, testOrg.ID, firstAccountID) + require.Nil(t, err) + assert.Equal(t, firstAccountID, *updated1.StripeAccountID) + + secondAccountID := "acct_second456" + updated2, err := repo.SetStripeAccountID(ctx, testOrg.ID, secondAccountID) + require.Nil(t, err) + assert.Equal(t, secondAccountID, *updated2.StripeAccountID) + assert.False(t, updated2.StripeAccountActivated) +} + +func TestSetStripeAccountID_NotFound(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + nonExistentID := uuid.New() + stripeAccountID := "acct_1SwvOB2Sjs4wsi8o" + + updated, err := repo.SetStripeAccountID(ctx, nonExistentID, stripeAccountID) + + require.NotNil(t, err) + assert.Nil(t, updated) +} + +func TestSetStripeAccountID_DoesNotModifyOtherFields(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + testOrg := CreateTestOrganization(t, ctx, testDB) + originalName := testOrg.Name + originalActive := testOrg.Active + + stripeAccountID := "acct_1SwvOB2Sjs4wsi8o" + updated, err := repo.SetStripeAccountID(ctx, testOrg.ID, stripeAccountID) + + require.Nil(t, err) + assert.Equal(t, originalName, updated.Name) + assert.Equal(t, originalActive, updated.Active) + assert.Equal(t, stripeAccountID, *updated.StripeAccountID) + assert.False(t, updated.StripeAccountActivated) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/sql/create.sql b/backend/internal/storage/postgres/schema/organization/sql/create.sql index 79ffccc5..d4d73e3d 100644 --- a/backend/internal/storage/postgres/schema/organization/sql/create.sql +++ b/backend/internal/storage/postgres/schema/organization/sql/create.sql @@ -1,3 +1,3 @@ INSERT INTO organization (name, active, pfp_s3_key, location_id) VALUES ($1, $2, $3, $4) -RETURNING id, name, active, pfp_s3_key, location_id, created_at, updated_at \ No newline at end of file +RETURNING id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at; diff --git a/backend/internal/storage/postgres/schema/organization/sql/delete.sql b/backend/internal/storage/postgres/schema/organization/sql/delete.sql index 6ac5ba60..6cc7817b 100644 --- a/backend/internal/storage/postgres/schema/organization/sql/delete.sql +++ b/backend/internal/storage/postgres/schema/organization/sql/delete.sql @@ -1,3 +1,3 @@ DELETE FROM organization WHERE id = $1 -RETURNING id, name, active, pfp_s3_key, location_id, created_at, updated_at \ No newline at end of file +RETURNING id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at; diff --git a/backend/internal/storage/postgres/schema/organization/sql/get_all.sql b/backend/internal/storage/postgres/schema/organization/sql/get_all.sql index aec2ba75..f7d411f7 100644 --- a/backend/internal/storage/postgres/schema/organization/sql/get_all.sql +++ b/backend/internal/storage/postgres/schema/organization/sql/get_all.sql @@ -1,4 +1,4 @@ -SELECT id, name, active, pfp_s3_key, location_id, created_at, updated_at +SELECT id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at FROM organization ORDER BY created_at DESC LIMIT $1 OFFSET $2 \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/sql/get_by_id.sql b/backend/internal/storage/postgres/schema/organization/sql/get_by_id.sql index 0f0bbf8c..a16342a5 100644 --- a/backend/internal/storage/postgres/schema/organization/sql/get_by_id.sql +++ b/backend/internal/storage/postgres/schema/organization/sql/get_by_id.sql @@ -1,3 +1,3 @@ -SELECT id, name, active, pfp_s3_key, location_id, created_at, updated_at +SELECT id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at FROM organization -WHERE id = $1 \ No newline at end of file +WHERE id = $1; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/sql/set_stripe_account_activated.sql b/backend/internal/storage/postgres/schema/organization/sql/set_stripe_account_activated.sql new file mode 100644 index 00000000..31be8b0b --- /dev/null +++ b/backend/internal/storage/postgres/schema/organization/sql/set_stripe_account_activated.sql @@ -0,0 +1,6 @@ +UPDATE organization +SET + stripe_account_activated = $1, + updated_at = NOW() +WHERE stripe_account_id = $2 +RETURNING id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/sql/set_stripe_account_id.sql b/backend/internal/storage/postgres/schema/organization/sql/set_stripe_account_id.sql new file mode 100644 index 00000000..63a5b533 --- /dev/null +++ b/backend/internal/storage/postgres/schema/organization/sql/set_stripe_account_id.sql @@ -0,0 +1,6 @@ +UPDATE organization +SET + stripe_account_id = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/organization/sql/update.sql b/backend/internal/storage/postgres/schema/organization/sql/update.sql index 5e5715a0..ccaffc96 100644 --- a/backend/internal/storage/postgres/schema/organization/sql/update.sql +++ b/backend/internal/storage/postgres/schema/organization/sql/update.sql @@ -1,4 +1,4 @@ UPDATE organization SET name = $1, active = $2, pfp_s3_key = $3, location_id = $4, updated_at = Now() WHERE id = $5 -RETURNING id, name, active, pfp_s3_key, location_id, created_at, updated_at; +RETURNING id, name, active, pfp_s3_key, location_id, stripe_account_id, stripe_account_activated, created_at, updated_at; diff --git a/backend/internal/storage/postgres/schema/organization/update.go b/backend/internal/storage/postgres/schema/organization/update.go index 23008524..5104e79e 100644 --- a/backend/internal/storage/postgres/schema/organization/update.go +++ b/backend/internal/storage/postgres/schema/organization/update.go @@ -48,6 +48,8 @@ func (r *OrganizationRepository) UpdateOrganization(ctx context.Context, input * &updatedOrganization.Active, &updatedOrganization.PfpS3Key, &updatedOrganization.LocationID, + &updatedOrganization.StripeAccountID, + &updatedOrganization.StripeAccountActivated, &updatedOrganization.CreatedAt, &updatedOrganization.UpdatedAt, ) diff --git a/backend/internal/storage/postgres/schema/organization/update_test.go b/backend/internal/storage/postgres/schema/organization/update_test.go index 7a43bc2f..10237866 100644 --- a/backend/internal/storage/postgres/schema/organization/update_test.go +++ b/backend/internal/storage/postgres/schema/organization/update_test.go @@ -18,7 +18,6 @@ func TestUpdateOrganization(t *testing.T) { ctx := context.Background() t.Parallel() - // Create an organization first active := true createInput := func() *models.CreateOrganizationInput { i := &models.CreateOrganizationInput{} @@ -31,7 +30,6 @@ func TestUpdateOrganization(t *testing.T) { require.Nil(t, createErr) require.NotNil(t, created) - // Update it newName := "Updated Name" newActive := false updateInput := &models.UpdateOrganizationInput{ @@ -47,12 +45,15 @@ func TestUpdateOrganization(t *testing.T) { require.NotNil(t, updated) assert.Equal(t, "Updated Name", updated.Name) assert.False(t, updated.Active) + assert.Nil(t, updated.StripeAccountID) + assert.False(t, updated.StripeAccountActivated) - // Verify update persisted fetched, getErr := repo.GetOrganizationByID(ctx, created.ID) require.Nil(t, getErr) assert.Equal(t, "Updated Name", fetched.Name) assert.False(t, fetched.Active) + assert.Nil(t, fetched.StripeAccountID) + assert.False(t, fetched.StripeAccountActivated) } func TestUpdateOrganization_WithLocation(t *testing.T) { @@ -61,7 +62,6 @@ func TestUpdateOrganization_WithLocation(t *testing.T) { ctx := context.Background() t.Parallel() - // Create organization active := true createInput := func() *models.CreateOrganizationInput { i := &models.CreateOrganizationInput{} @@ -74,7 +74,6 @@ func TestUpdateOrganization_WithLocation(t *testing.T) { require.Nil(t, createErr) require.NotNil(t, created) - // Update with location locationID := location.CreateTestLocation(t, ctx, testDB).ID newName := "Test Org with Location" updateInput := &models.UpdateOrganizationInput{ @@ -90,6 +89,8 @@ func TestUpdateOrganization_WithLocation(t *testing.T) { require.NotNil(t, updated) assert.Equal(t, "Test Org with Location", updated.Name) assert.Equal(t, &locationID, updated.LocationID) + assert.Nil(t, updated.StripeAccountID) + assert.False(t, updated.StripeAccountActivated) } func TestUpdateOrganization_NotFound(t *testing.T) { @@ -98,7 +99,6 @@ func TestUpdateOrganization_NotFound(t *testing.T) { ctx := context.Background() t.Parallel() - // Try to update non-existent organization nonExistentID := uuid.New() newName := "Does Not Exist" updateInput := &models.UpdateOrganizationInput{ @@ -113,3 +113,35 @@ func TestUpdateOrganization_NotFound(t *testing.T) { require.NotNil(t, err) assert.Nil(t, updated) } + +func TestUpdateOrganization_DoesNotModifyStripeFields(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewOrganizationRepository(testDB) + ctx := context.Background() + t.Parallel() + + active := true + createInput := func() *models.CreateOrganizationInput { + i := &models.CreateOrganizationInput{} + i.Body.Name = "Stripe Test Org" + i.Body.Active = &active + return i + }() + + created, createErr := repo.CreateOrganization(ctx, createInput, nil) + require.Nil(t, createErr) + + newName := "Updated Stripe Org" + updateInput := &models.UpdateOrganizationInput{ + ID: created.ID, + Body: models.UpdateOrganizationBody{ + Name: &newName, + }, + } + + updated, updateErr := repo.UpdateOrganization(ctx, updateInput, nil) + require.Nil(t, updateErr) + assert.Equal(t, "Updated Stripe Org", updated.Name) + assert.Nil(t, updated.StripeAccountID) + assert.False(t, updated.StripeAccountActivated) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/cancel_registration.go b/backend/internal/storage/postgres/schema/registration/cancel_registration.go new file mode 100644 index 00000000..ca2c4168 --- /dev/null +++ b/backend/internal/storage/postgres/schema/registration/cancel_registration.go @@ -0,0 +1,57 @@ +package registration + +import ( + "context" + "errors" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/jackc/pgx/v5" +) + +func (r *RegistrationRepository) CancelRegistration(ctx context.Context, input *models.CancelRegistrationInput) (*models.CancelRegistrationOutput, error) { + query, err := schema.ReadSQLBaseScript("registration/sql/cancel_registration.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, input.ID) + + var output models.CancelRegistrationOutput + + err = row.Scan( + &output.Body.Registration.ID, + &output.Body.Registration.ChildID, + &output.Body.Registration.GuardianID, + &output.Body.Registration.EventOccurrenceID, + &output.Body.Registration.Status, + &output.Body.Registration.CreatedAt, + &output.Body.Registration.UpdatedAt, + &output.Body.Registration.StripeCustomerID, + &output.Body.Registration.OrgStripeAccountID, + &output.Body.Registration.Currency, + &output.Body.Registration.PaymentIntentStatus, + &output.Body.Registration.CancelledAt, + &output.Body.Registration.StripePaymentIntentID, + &output.Body.Registration.TotalAmount, + &output.Body.Registration.ProviderAmount, + &output.Body.Registration.PlatformFeeAmount, + &output.Body.Registration.PaidAt, + &output.Body.Registration.StripePaymentMethodID, + &output.Body.Registration.EventName, + &output.Body.Registration.OccurrenceStartTime, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + errr := errs.NotFound("Registration", "id", input.ID) + return nil, &errr + } + errr := errs.InternalServerError("Failed to cancel registration: ", err.Error()) + return nil, &errr + } + + return &output, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/cancel_registration_test.go b/backend/internal/storage/postgres/schema/registration/cancel_registration_test.go new file mode 100644 index 00000000..bfb96695 --- /dev/null +++ b/backend/internal/storage/postgres/schema/registration/cancel_registration_test.go @@ -0,0 +1,109 @@ +package registration + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCancelRegistration(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + require.Equal(t, models.RegistrationStatusRegistered, created.Status) + require.Nil(t, created.CancelledAt) + + input := &models.CancelRegistrationInput{ + ID: created.ID, + } + + cancelled, err := repo.CancelRegistration(ctx, input) + + require.Nil(t, err) + require.NotNil(t, cancelled) + assert.Equal(t, created.ID, cancelled.Body.Registration.ID) + assert.Equal(t, models.RegistrationStatusCancelled, cancelled.Body.Registration.Status) + assert.NotNil(t, cancelled.Body.Registration.CancelledAt) + assert.NotZero(t, cancelled.Body.Registration.CancelledAt) + + assert.Equal(t, created.ChildID, cancelled.Body.Registration.ChildID) + assert.Equal(t, created.GuardianID, cancelled.Body.Registration.GuardianID) + assert.Equal(t, created.EventOccurrenceID, cancelled.Body.Registration.EventOccurrenceID) + assert.Equal(t, created.StripePaymentIntentID, cancelled.Body.Registration.StripePaymentIntentID) + assert.Equal(t, created.TotalAmount, cancelled.Body.Registration.TotalAmount) + assert.Equal(t, created.Currency, cancelled.Body.Registration.Currency) + assert.NotEmpty(t, cancelled.Body.Registration.EventName) + assert.NotZero(t, cancelled.Body.Registration.OccurrenceStartTime) +} + +func TestCancelRegistration_AlreadyCancelled(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.CancelRegistrationInput{ + ID: created.ID, + } + _, err := repo.CancelRegistration(ctx, input) + require.Nil(t, err) + + cancelled, err := repo.CancelRegistration(ctx, input) + + require.Nil(t, err) + require.NotNil(t, cancelled) + assert.Equal(t, models.RegistrationStatusCancelled, cancelled.Body.Registration.Status) + assert.NotNil(t, cancelled.Body.Registration.CancelledAt) +} + +func TestCancelRegistration_NotFound(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + input := &models.CancelRegistrationInput{ + ID: uuid.New(), + } + + cancelled, err := repo.CancelRegistration(ctx, input) + + require.NotNil(t, err) + assert.Nil(t, cancelled) +} + +func TestCancelRegistration_VerifyPersistence(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.CancelRegistrationInput{ + ID: created.ID, + } + cancelled, err := repo.CancelRegistration(ctx, input) + require.Nil(t, err) + + getInput := &models.GetRegistrationByIDInput{ + ID: created.ID, + } + fetched, err := repo.GetRegistrationByID(ctx, getInput) + + require.Nil(t, err) + require.NotNil(t, fetched) + assert.Equal(t, models.RegistrationStatusCancelled, fetched.Body.Status) + assert.NotNil(t, fetched.Body.CancelledAt) + assert.Equal(t, cancelled.Body.Registration.CancelledAt, fetched.Body.CancelledAt) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/create.go b/backend/internal/storage/postgres/schema/registration/create.go index 62c9b6e0..8190b4a8 100644 --- a/backend/internal/storage/postgres/schema/registration/create.go +++ b/backend/internal/storage/postgres/schema/registration/create.go @@ -7,7 +7,7 @@ import ( "skillspark/internal/storage/postgres/schema" ) -func (r *RegistrationRepository) CreateRegistration(ctx context.Context, input *models.CreateRegistrationInput) (*models.CreateRegistrationOutput, error) { +func (r *RegistrationRepository) CreateRegistration(ctx context.Context, input *models.CreateRegistrationWithPaymentData) (*models.CreateRegistrationOutput, error) { query, err := schema.ReadSQLBaseScript("registration/sql/create.sql") if err != nil { @@ -16,10 +16,19 @@ func (r *RegistrationRepository) CreateRegistration(ctx context.Context, input * } row := r.db.QueryRow(ctx, query, - input.Body.ChildID, - input.Body.GuardianID, - input.Body.EventOccurrenceID, - input.Body.Status) + input.ChildID, + input.GuardianID, + input.EventOccurrenceID, + input.Status, + input.StripePaymentIntentID, + input.StripeCustomerID, + input.OrgStripeAccountID, + input.StripePaymentMethodID, + input.TotalAmount, + input.ProviderAmount, + input.PlatformFeeAmount, + input.Currency, + input.PaymentIntentStatus) var createdRegistration models.CreateRegistrationOutput @@ -31,8 +40,19 @@ func (r *RegistrationRepository) CreateRegistration(ctx context.Context, input * &createdRegistration.Body.Status, &createdRegistration.Body.CreatedAt, &createdRegistration.Body.UpdatedAt, + &createdRegistration.Body.StripeCustomerID, + &createdRegistration.Body.OrgStripeAccountID, + &createdRegistration.Body.Currency, + &createdRegistration.Body.PaymentIntentStatus, + &createdRegistration.Body.CancelledAt, + &createdRegistration.Body.StripePaymentIntentID, + &createdRegistration.Body.TotalAmount, + &createdRegistration.Body.ProviderAmount, + &createdRegistration.Body.PlatformFeeAmount, + &createdRegistration.Body.PaidAt, + &createdRegistration.Body.StripePaymentMethodID, &createdRegistration.Body.EventName, - &createdRegistration.Body.OccurrenceStartTime, + &createdRegistration.Body.OccurrenceStartTime, ) if err != nil { diff --git a/backend/internal/storage/postgres/schema/registration/create_test.go b/backend/internal/storage/postgres/schema/registration/create_test.go index 8eb8adf2..a3ae32d6 100644 --- a/backend/internal/storage/postgres/schema/registration/create_test.go +++ b/backend/internal/storage/postgres/schema/registration/create_test.go @@ -15,91 +15,56 @@ import ( func TestCreateRegistration(t *testing.T) { testDB := testutil.SetupTestDB(t) - repo := NewRegistrationRepository(testDB) ctx := context.Background() t.Parallel() - child := child.CreateTestChild(t, ctx, testDB) - occurrence := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - childID := child.ID - guardianID := child.GuardianID - occurrenceID := occurrence.ID - - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = child.ID - i.Body.GuardianID = child.GuardianID - i.Body.EventOccurrenceID = occurrence.ID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() - - created, err := repo.CreateRegistration(ctx, input) + created := CreateTestRegistration(t, ctx, testDB) - require.Nil(t, err) require.NotNil(t, created) - assert.Equal(t, childID, created.Body.ChildID) - assert.Equal(t, guardianID, created.Body.GuardianID) - assert.Equal(t, occurrenceID, created.Body.EventOccurrenceID) - assert.Equal(t, models.RegistrationStatusRegistered, created.Body.Status) - assert.NotEqual(t, uuid.Nil, created.Body.ID) - assert.NotZero(t, created.Body.CreatedAt) - assert.NotZero(t, created.Body.UpdatedAt) - assert.NotEmpty(t, created.Body.EventName) - assert.NotZero(t, created.Body.OccurrenceStartTime) + assert.NotEqual(t, uuid.Nil, created.ID) + assert.NotEqual(t, uuid.Nil, created.ChildID) + assert.NotEqual(t, uuid.Nil, created.GuardianID) + assert.NotEqual(t, uuid.Nil, created.EventOccurrenceID) + assert.Equal(t, models.RegistrationStatusRegistered, created.Status) + assert.NotZero(t, created.CreatedAt) + assert.NotZero(t, created.UpdatedAt) + assert.NotEmpty(t, created.EventName) + assert.NotZero(t, created.OccurrenceStartTime) + + // Verify payment fields + assert.NotEmpty(t, created.StripePaymentIntentID) + assert.NotEmpty(t, created.StripeCustomerID) + assert.Equal(t, "acct_test_123", created.OrgStripeAccountID) + assert.Equal(t, "pm_test_123", created.StripePaymentMethodID) + assert.Equal(t, 10000, created.TotalAmount) + assert.Equal(t, 8500, created.ProviderAmount) + assert.Equal(t, 1500, created.PlatformFeeAmount) + assert.Equal(t, "usd", created.Currency) + assert.Equal(t, "requires_capture", created.PaymentIntentStatus) + assert.Nil(t, created.CancelledAt) + assert.Nil(t, created.PaidAt) } func TestCreateRegistration_VerifyEventNameJoin(t *testing.T) { testDB := testutil.SetupTestDB(t) - repo := NewRegistrationRepository(testDB) ctx := context.Background() t.Parallel() - child := child.CreateTestChild(t, ctx, testDB) - occurrence := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = child.ID - i.Body.GuardianID = child.GuardianID - i.Body.EventOccurrenceID = occurrence.ID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() - - created, err := repo.CreateRegistration(ctx, input) + created := CreateTestRegistration(t, ctx, testDB) - require.Nil(t, err) require.NotNil(t, created) - assert.NotEmpty(t, created.Body.EventName) + assert.NotEmpty(t, created.EventName) } func TestCreateRegistration_VerifyOccurrenceStartTime(t *testing.T) { testDB := testutil.SetupTestDB(t) - repo := NewRegistrationRepository(testDB) ctx := context.Background() t.Parallel() - child := child.CreateTestChild(t, ctx, testDB) - occurrence := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - childID := child.ID - guardianID := child.GuardianID - occurrenceID := occurrence.ID - - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = occurrenceID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() - - created, err := repo.CreateRegistration(ctx, input) + created := CreateTestRegistration(t, ctx, testDB) - require.Nil(t, err) require.NotNil(t, created) - assert.NotZero(t, created.Body.OccurrenceStartTime) + assert.NotZero(t, created.OccurrenceStartTime) } func TestCreateRegistration_MultipleRegistrationsForSameChild(t *testing.T) { @@ -109,34 +74,44 @@ func TestCreateRegistration_MultipleRegistrationsForSameChild(t *testing.T) { t.Parallel() child := child.CreateTestChild(t, ctx, testDB) - childID := child.ID - guardianID := child.GuardianID o1 := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - o1ID := o1.ID o2 := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - o2ID := o2.ID - input1 := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = o1ID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + input1 := &models.CreateRegistrationWithPaymentData{ + ChildID: child.ID, + GuardianID: child.GuardianID, + EventOccurrenceID: o1.ID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_1", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created1, err := repo.CreateRegistration(ctx, input1) require.Nil(t, err) require.NotNil(t, created1) - input2 := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = o2ID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + input2 := &models.CreateRegistrationWithPaymentData{ + ChildID: child.ID, + GuardianID: child.GuardianID, + EventOccurrenceID: o2.ID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_2", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created2, err := repo.CreateRegistration(ctx, input2) require.Nil(t, err) @@ -154,17 +129,22 @@ func TestCreateRegistration_InvalidChildID(t *testing.T) { child := child.CreateTestChild(t, ctx, testDB) occurrence := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - guardianID := child.GuardianID - occurrenceID := occurrence.ID - - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = uuid.New() - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = occurrenceID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + + input := &models.CreateRegistrationWithPaymentData{ + ChildID: uuid.New(), + GuardianID: child.GuardianID, + EventOccurrenceID: occurrence.ID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created, err := repo.CreateRegistration(ctx, input) @@ -180,17 +160,22 @@ func TestCreateRegistration_InvalidGuardianID(t *testing.T) { child := child.CreateTestChild(t, ctx, testDB) occurrence := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB) - childID := child.ID - occurrenceID := occurrence.ID - - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = uuid.New() - i.Body.EventOccurrenceID = occurrenceID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + + input := &models.CreateRegistrationWithPaymentData{ + ChildID: child.ID, + GuardianID: uuid.New(), + EventOccurrenceID: occurrence.ID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created, err := repo.CreateRegistration(ctx, input) @@ -205,20 +190,25 @@ func TestCreateRegistration_InvalidEventOccurrenceID(t *testing.T) { t.Parallel() child := child.CreateTestChild(t, ctx, testDB) - childID := child.ID - guardianID := child.GuardianID - - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = uuid.New() - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + + input := &models.CreateRegistrationWithPaymentData{ + ChildID: child.ID, + GuardianID: child.GuardianID, + EventOccurrenceID: uuid.New(), + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created, err := repo.CreateRegistration(ctx, input) require.NotNil(t, err) require.Nil(t, created) -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/get_by_child_id.go b/backend/internal/storage/postgres/schema/registration/get_by_child_id.go index 4ab94b7e..29b23837 100644 --- a/backend/internal/storage/postgres/schema/registration/get_by_child_id.go +++ b/backend/internal/storage/postgres/schema/registration/get_by_child_id.go @@ -46,8 +46,19 @@ func scanRegistration(row pgx.CollectableRow) (models.Registration, error) { ®istration.Status, ®istration.CreatedAt, ®istration.UpdatedAt, + ®istration.StripeCustomerID, + ®istration.OrgStripeAccountID, + ®istration.Currency, + ®istration.PaymentIntentStatus, + ®istration.CancelledAt, + ®istration.StripePaymentIntentID, + ®istration.TotalAmount, + ®istration.ProviderAmount, + ®istration.PlatformFeeAmount, + ®istration.PaidAt, + ®istration.StripePaymentMethodID, ®istration.EventName, ®istration.OccurrenceStartTime, ) return registration, err -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/get_by_child_id_test.go b/backend/internal/storage/postgres/schema/registration/get_by_child_id_test.go index b732eff4..1bd5805a 100644 --- a/backend/internal/storage/postgres/schema/registration/get_by_child_id_test.go +++ b/backend/internal/storage/postgres/schema/registration/get_by_child_id_test.go @@ -39,6 +39,17 @@ func TestGetRegistrationsByChildID(t *testing.T) { assert.NotZero(t, reg.CreatedAt) assert.NotZero(t, reg.UpdatedAt) assert.NotZero(t, reg.OccurrenceStartTime) + + // Verify payment fields + assert.NotEmpty(t, reg.StripePaymentIntentID) + assert.NotEmpty(t, reg.StripeCustomerID) + assert.NotEmpty(t, reg.OrgStripeAccountID) + assert.NotEmpty(t, reg.StripePaymentMethodID) + assert.NotZero(t, reg.TotalAmount) + assert.NotZero(t, reg.ProviderAmount) + assert.NotZero(t, reg.PlatformFeeAmount) + assert.NotEmpty(t, reg.Currency) + assert.NotEmpty(t, reg.PaymentIntentStatus) } } @@ -110,4 +121,4 @@ func TestGetRegistrationsByChildID_VerifyEventDetails(t *testing.T) { assert.NotEmpty(t, reg.EventName) assert.NotZero(t, reg.OccurrenceStartTime) } -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/get_by_event_occurrence_id_test.go b/backend/internal/storage/postgres/schema/registration/get_by_event_occurrence_id_test.go index 4bc4ec50..a40eff8d 100644 --- a/backend/internal/storage/postgres/schema/registration/get_by_event_occurrence_id_test.go +++ b/backend/internal/storage/postgres/schema/registration/get_by_event_occurrence_id_test.go @@ -41,6 +41,17 @@ func TestGetRegistrationsByEventOccurrenceID(t *testing.T) { assert.NotZero(t, reg.CreatedAt) assert.NotZero(t, reg.UpdatedAt) assert.NotZero(t, reg.OccurrenceStartTime) + + // Verify payment fields + assert.NotEmpty(t, reg.StripePaymentIntentID) + assert.NotEmpty(t, reg.StripeCustomerID) + assert.NotEmpty(t, reg.OrgStripeAccountID) + assert.NotEmpty(t, reg.StripePaymentMethodID) + assert.NotZero(t, reg.TotalAmount) + assert.NotZero(t, reg.ProviderAmount) + assert.NotZero(t, reg.PlatformFeeAmount) + assert.NotEmpty(t, reg.Currency) + assert.NotEmpty(t, reg.PaymentIntentStatus) } } @@ -112,4 +123,4 @@ func TestGetRegistrationsByEventOccurrenceID_VerifyEventDetails(t *testing.T) { assert.NotEmpty(t, reg.EventName) assert.NotZero(t, reg.OccurrenceStartTime) } -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/get_by_guardian_id_test.go b/backend/internal/storage/postgres/schema/registration/get_by_guardian_id_test.go index c9cd09ec..c8a893ab 100644 --- a/backend/internal/storage/postgres/schema/registration/get_by_guardian_id_test.go +++ b/backend/internal/storage/postgres/schema/registration/get_by_guardian_id_test.go @@ -39,6 +39,17 @@ func TestGetRegistrationsByGuardianID(t *testing.T) { assert.NotZero(t, reg.CreatedAt) assert.NotZero(t, reg.UpdatedAt) assert.NotZero(t, reg.OccurrenceStartTime) + + // Verify payment fields + assert.NotEmpty(t, reg.StripePaymentIntentID) + assert.NotEmpty(t, reg.StripeCustomerID) + assert.NotEmpty(t, reg.OrgStripeAccountID) + assert.NotEmpty(t, reg.StripePaymentMethodID) + assert.NotZero(t, reg.TotalAmount) + assert.NotZero(t, reg.ProviderAmount) + assert.NotZero(t, reg.PlatformFeeAmount) + assert.NotEmpty(t, reg.Currency) + assert.NotEmpty(t, reg.PaymentIntentStatus) } } @@ -111,4 +122,4 @@ func TestGetRegistrationsByGuardianID_VerifyEventDetails(t *testing.T) { assert.NotEmpty(t, reg.EventName) assert.NotZero(t, reg.OccurrenceStartTime) } -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/get_by_id.go b/backend/internal/storage/postgres/schema/registration/get_by_id.go index 202a9d46..480cfdf6 100644 --- a/backend/internal/storage/postgres/schema/registration/get_by_id.go +++ b/backend/internal/storage/postgres/schema/registration/get_by_id.go @@ -29,6 +29,17 @@ func (r *RegistrationRepository) GetRegistrationByID(ctx context.Context, input ®istration.Body.Status, ®istration.Body.CreatedAt, ®istration.Body.UpdatedAt, + ®istration.Body.StripeCustomerID, + ®istration.Body.OrgStripeAccountID, + ®istration.Body.Currency, + ®istration.Body.PaymentIntentStatus, + ®istration.Body.CancelledAt, + ®istration.Body.StripePaymentIntentID, + ®istration.Body.TotalAmount, + ®istration.Body.ProviderAmount, + ®istration.Body.PlatformFeeAmount, + ®istration.Body.PaidAt, + ®istration.Body.StripePaymentMethodID, ®istration.Body.EventName, ®istration.Body.OccurrenceStartTime, ) @@ -43,4 +54,4 @@ func (r *RegistrationRepository) GetRegistrationByID(ctx context.Context, input } return ®istration, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/get_by_id_test.go b/backend/internal/storage/postgres/schema/registration/get_by_id_test.go index 06c0f7cf..7ab8acc5 100644 --- a/backend/internal/storage/postgres/schema/registration/get_by_id_test.go +++ b/backend/internal/storage/postgres/schema/registration/get_by_id_test.go @@ -37,6 +37,19 @@ func TestGetRegistrationByID(t *testing.T) { assert.NotZero(t, retrieved.Body.CreatedAt) assert.NotZero(t, retrieved.Body.UpdatedAt) assert.NotZero(t, retrieved.Body.OccurrenceStartTime) + + // Verify payment fields + assert.NotEmpty(t, retrieved.Body.StripePaymentIntentID) + assert.NotEmpty(t, retrieved.Body.StripeCustomerID) + assert.NotEmpty(t, retrieved.Body.OrgStripeAccountID) + assert.NotEmpty(t, retrieved.Body.StripePaymentMethodID) + assert.NotZero(t, retrieved.Body.TotalAmount) + assert.NotZero(t, retrieved.Body.ProviderAmount) + assert.NotZero(t, retrieved.Body.PlatformFeeAmount) + assert.NotEmpty(t, retrieved.Body.Currency) + assert.NotEmpty(t, retrieved.Body.PaymentIntentStatus) + assert.Nil(t, retrieved.Body.CancelledAt) + assert.Nil(t, retrieved.Body.PaidAt) } func TestGetRegistrationByID_VerifyEventDetails(t *testing.T) { @@ -99,4 +112,4 @@ func TestGetRegistrationByID_MultipleDifferentRegistrations(t *testing.T) { require.NotNil(t, retrieved2) assert.NotEqual(t, retrieved1.Body.ID, retrieved2.Body.ID) -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/sql/cancel_registration.sql b/backend/internal/storage/postgres/schema/registration/sql/cancel_registration.sql new file mode 100644 index 00000000..dd4bba6f --- /dev/null +++ b/backend/internal/storage/postgres/schema/registration/sql/cancel_registration.sql @@ -0,0 +1,51 @@ +WITH updated AS ( + UPDATE registration + SET + status = 'cancelled', + cancelled_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING + id, + child_id, + guardian_id, + event_occurrence_id, + status, + created_at, + updated_at, + stripe_customer_id, + org_stripe_account_id, + currency, + payment_intent_status, + cancelled_at, + stripe_payment_intent_id, + total_amount, + provider_amount, + platform_fee_amount, + paid_at, + stripe_payment_method_id +) +SELECT + u.id, + u.child_id, + u.guardian_id, + u.event_occurrence_id, + u.status, + u.created_at, + u.updated_at, + u.stripe_customer_id, + u.org_stripe_account_id, + u.currency, + u.payment_intent_status, + u.cancelled_at, + u.stripe_payment_intent_id, + u.total_amount, + u.provider_amount, + u.platform_fee_amount, + u.paid_at, + u.stripe_payment_method_id, + e.title AS event_name, + eo.start_time AS occurrence_start_time +FROM updated u +JOIN event_occurrence eo ON u.event_occurrence_id = eo.id +JOIN event e ON eo.event_id = e.id; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/sql/create.sql b/backend/internal/storage/postgres/schema/registration/sql/create.sql index c4f72627..10a6f5aa 100644 --- a/backend/internal/storage/postgres/schema/registration/sql/create.sql +++ b/backend/internal/storage/postgres/schema/registration/sql/create.sql @@ -1,16 +1,59 @@ WITH inserted AS ( - INSERT INTO registration (child_id, guardian_id, event_occurrence_id, status) - VALUES ($1, $2, $3, $4) - RETURNING id, child_id, guardian_id, event_occurrence_id, status, created_at, updated_at + INSERT INTO registration ( + child_id, + guardian_id, + event_occurrence_id, + status, + stripe_payment_intent_id, + stripe_customer_id, + org_stripe_account_id, + stripe_payment_method_id, + total_amount, + provider_amount, + platform_fee_amount, + currency, + payment_intent_status + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING + id, + child_id, + guardian_id, + event_occurrence_id, + status, + created_at, + updated_at, + stripe_customer_id, + org_stripe_account_id, + currency, + payment_intent_status, + cancelled_at, + stripe_payment_intent_id, + total_amount, + provider_amount, + platform_fee_amount, + paid_at, + stripe_payment_method_id ) SELECT - i.id as id, - i.child_id as child_id, - i.guardian_id as guardian_id, - i.event_occurrence_id as event_occurrence_id, - i.status as status, - i.created_at as created_at, - i.updated_at as updated_at, + i.id, + i.child_id, + i.guardian_id, + i.event_occurrence_id, + i.status, + i.created_at, + i.updated_at, + i.stripe_customer_id, + i.org_stripe_account_id, + i.currency, + i.payment_intent_status, + i.cancelled_at, + i.stripe_payment_intent_id, + i.total_amount, + i.provider_amount, + i.platform_fee_amount, + i.paid_at, + i.stripe_payment_method_id, e.title AS event_name, eo.start_time AS occurrence_start_time FROM inserted i diff --git a/backend/internal/storage/postgres/schema/registration/sql/get_by_child_id.sql b/backend/internal/storage/postgres/schema/registration/sql/get_by_child_id.sql index c656f2ef..3b349ade 100644 --- a/backend/internal/storage/postgres/schema/registration/sql/get_by_child_id.sql +++ b/backend/internal/storage/postgres/schema/registration/sql/get_by_child_id.sql @@ -1,11 +1,22 @@ SELECT - r.id AS id, - r.child_id AS child_id, - r.guardian_id AS guardian_id, - r.event_occurrence_id AS event_occurrence_id, - r.status AS status, - r.created_at AS created_at, - r.updated_at AS updated_at, + r.id, + r.child_id, + r.guardian_id, + r.event_occurrence_id, + r.status, + r.created_at, + r.updated_at, + r.stripe_customer_id, + r.org_stripe_account_id, + r.currency, + r.payment_intent_status, + r.cancelled_at, + r.stripe_payment_intent_id, + r.total_amount, + r.provider_amount, + r.platform_fee_amount, + r.paid_at, + r.stripe_payment_method_id, e.title AS event_name, eo.start_time AS occurrence_start_time FROM registration r diff --git a/backend/internal/storage/postgres/schema/registration/sql/get_by_event_occurrence_id.sql b/backend/internal/storage/postgres/schema/registration/sql/get_by_event_occurrence_id.sql index d5d0d2c6..30aa1c7b 100644 --- a/backend/internal/storage/postgres/schema/registration/sql/get_by_event_occurrence_id.sql +++ b/backend/internal/storage/postgres/schema/registration/sql/get_by_event_occurrence_id.sql @@ -1,14 +1,25 @@ SELECT - r.id AS id, - r.child_id AS child_id, - r.guardian_id AS guardian_id, - r.event_occurrence_id AS event_occurrence_id, - r.status AS status, - r.created_at AS created_at, - r.updated_at AS updateed_at, + r.id, + r.child_id, + r.guardian_id, + r.event_occurrence_id, + r.status, + r.created_at, + r.updated_at, + r.stripe_customer_id, + r.org_stripe_account_id, + r.currency, + r.payment_intent_status, + r.cancelled_at, + r.stripe_payment_intent_id, + r.total_amount, + r.provider_amount, + r.platform_fee_amount, + r.paid_at, + r.stripe_payment_method_id, e.title AS event_name, eo.start_time AS occurrence_start_time FROM registration r JOIN event_occurrence eo ON r.event_occurrence_id = eo.id JOIN event e ON eo.event_id = e.id -WHERE $1 = r.event_occurrence_id \ No newline at end of file +WHERE r.event_occurrence_id = $1 \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/sql/get_by_guardian_id.sql b/backend/internal/storage/postgres/schema/registration/sql/get_by_guardian_id.sql index c264d1cf..22563073 100644 --- a/backend/internal/storage/postgres/schema/registration/sql/get_by_guardian_id.sql +++ b/backend/internal/storage/postgres/schema/registration/sql/get_by_guardian_id.sql @@ -1,14 +1,25 @@ SELECT - r.id AS id, - r.child_id AS child_id, - r.guardian_id AS guardian_id, - r.event_occurrence_id AS event_occurrence_id, - r.status AS status, - r.created_at AS created_at, - r.updated_at AS updateed_at, + r.id, + r.child_id, + r.guardian_id, + r.event_occurrence_id, + r.status, + r.created_at, + r.updated_at, + r.stripe_customer_id, + r.org_stripe_account_id, + r.currency, + r.payment_intent_status, + r.cancelled_at, + r.stripe_payment_intent_id, + r.total_amount, + r.provider_amount, + r.platform_fee_amount, + r.paid_at, + r.stripe_payment_method_id, e.title AS event_name, eo.start_time AS occurrence_start_time FROM registration r JOIN event_occurrence eo ON r.event_occurrence_id = eo.id JOIN event e ON eo.event_id = e.id -WHERE $1 = r.guardian_id \ No newline at end of file +WHERE r.guardian_id = $1 \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/sql/get_by_id.sql b/backend/internal/storage/postgres/schema/registration/sql/get_by_id.sql index bacf53ba..16eac492 100644 --- a/backend/internal/storage/postgres/schema/registration/sql/get_by_id.sql +++ b/backend/internal/storage/postgres/schema/registration/sql/get_by_id.sql @@ -6,6 +6,17 @@ SELECT r.status, r.created_at, r.updated_at, + r.stripe_customer_id, + r.org_stripe_account_id, + r.currency, + r.payment_intent_status, + r.cancelled_at, + r.stripe_payment_intent_id, + r.total_amount, + r.provider_amount, + r.platform_fee_amount, + r.paid_at, + r.stripe_payment_method_id, e.title AS event_name, eo.start_time AS occurrence_start_time FROM registration r diff --git a/backend/internal/storage/postgres/schema/registration/sql/update.sql b/backend/internal/storage/postgres/schema/registration/sql/update.sql index d4bcb1eb..930d3025 100644 --- a/backend/internal/storage/postgres/schema/registration/sql/update.sql +++ b/backend/internal/storage/postgres/schema/registration/sql/update.sql @@ -2,12 +2,27 @@ WITH updated AS ( UPDATE registration SET child_id = $1, - guardian_id = $2, - event_occurrence_id = $3, - status = $4, updated_at = NOW() - WHERE id = $5 - RETURNING id, child_id, guardian_id, event_occurrence_id, status, created_at, updated_at + WHERE id = $2 + RETURNING + id, + child_id, + guardian_id, + event_occurrence_id, + status, + created_at, + updated_at, + stripe_customer_id, + org_stripe_account_id, + currency, + payment_intent_status, + cancelled_at, + stripe_payment_intent_id, + total_amount, + provider_amount, + platform_fee_amount, + paid_at, + stripe_payment_method_id ) SELECT u.id, @@ -17,6 +32,17 @@ SELECT u.status, u.created_at, u.updated_at, + u.stripe_customer_id, + u.org_stripe_account_id, + u.currency, + u.payment_intent_status, + u.cancelled_at, + u.stripe_payment_intent_id, + u.total_amount, + u.provider_amount, + u.platform_fee_amount, + u.paid_at, + u.stripe_payment_method_id, e.title AS event_name, eo.start_time AS occurrence_start_time FROM updated u diff --git a/backend/internal/storage/postgres/schema/registration/sql/update_payment_status.sql b/backend/internal/storage/postgres/schema/registration/sql/update_payment_status.sql new file mode 100644 index 00000000..3808134d --- /dev/null +++ b/backend/internal/storage/postgres/schema/registration/sql/update_payment_status.sql @@ -0,0 +1,54 @@ +WITH updated AS ( + UPDATE registration + SET + payment_intent_status = $2::payment_intent_status, + paid_at = CASE + WHEN $2 = 'succeeded' THEN NOW() + ELSE paid_at + END, + updated_at = NOW() + WHERE id = $1 + RETURNING + id, + child_id, + guardian_id, + event_occurrence_id, + status, + created_at, + updated_at, + stripe_customer_id, + org_stripe_account_id, + currency, + payment_intent_status, + cancelled_at, + stripe_payment_intent_id, + total_amount, + provider_amount, + platform_fee_amount, + paid_at, + stripe_payment_method_id +) +SELECT + u.id, + u.child_id, + u.guardian_id, + u.event_occurrence_id, + u.status, + u.created_at, + u.updated_at, + u.stripe_customer_id, + u.org_stripe_account_id, + u.currency, + u.payment_intent_status, + u.cancelled_at, + u.stripe_payment_intent_id, + u.total_amount, + u.provider_amount, + u.platform_fee_amount, + u.paid_at, + u.stripe_payment_method_id, + e.title AS event_name, + eo.start_time AS occurrence_start_time +FROM updated u +JOIN event_occurrence eo ON u.event_occurrence_id = eo.id +JOIN event e ON eo.event_id = e.id; \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/update.go b/backend/internal/storage/postgres/schema/registration/update.go index 0e180de0..f31693d3 100644 --- a/backend/internal/storage/postgres/schema/registration/update.go +++ b/backend/internal/storage/postgres/schema/registration/update.go @@ -17,34 +17,8 @@ func (r *RegistrationRepository) UpdateRegistration(ctx context.Context, input * return nil, &errr } - getInput := &models.GetRegistrationByIDInput{ - ID: input.ID, - } - existingOutput, httpErr := r.GetRegistrationByID(ctx, getInput) - if httpErr != nil { - return nil, httpErr - } - - existing := existingOutput.Body - - if input.Body.ChildID != nil { - existing.ChildID = *input.Body.ChildID - } - if input.Body.GuardianID != nil { - existing.GuardianID = *input.Body.GuardianID - } - if input.Body.EventOccurrenceID != nil { - existing.EventOccurrenceID = *input.Body.EventOccurrenceID - } - if input.Body.Status != nil { - existing.Status = *input.Body.Status - } - row := r.db.QueryRow(ctx, query, - existing.ChildID, - existing.GuardianID, - existing.EventOccurrenceID, - existing.Status, + input.Body.ChildID, input.ID, ) @@ -58,6 +32,17 @@ func (r *RegistrationRepository) UpdateRegistration(ctx context.Context, input * &updated.Body.Status, &updated.Body.CreatedAt, &updated.Body.UpdatedAt, + &updated.Body.StripeCustomerID, + &updated.Body.OrgStripeAccountID, + &updated.Body.Currency, + &updated.Body.PaymentIntentStatus, + &updated.Body.CancelledAt, + &updated.Body.StripePaymentIntentID, + &updated.Body.TotalAmount, + &updated.Body.ProviderAmount, + &updated.Body.PlatformFeeAmount, + &updated.Body.PaidAt, + &updated.Body.StripePaymentMethodID, &updated.Body.EventName, &updated.Body.OccurrenceStartTime, ) @@ -71,4 +56,4 @@ func (r *RegistrationRepository) UpdateRegistration(ctx context.Context, input * } return &updated, nil -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/update_payment_status.go b/backend/internal/storage/postgres/schema/registration/update_payment_status.go new file mode 100644 index 00000000..acaf1fbb --- /dev/null +++ b/backend/internal/storage/postgres/schema/registration/update_payment_status.go @@ -0,0 +1,57 @@ +package registration + +import ( + "context" + "errors" + "skillspark/internal/errs" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/schema" + + "github.com/jackc/pgx/v5" +) + +func (r *RegistrationRepository) UpdateRegistrationPaymentStatus(ctx context.Context, input *models.UpdateRegistrationPaymentStatusInput) (*models.UpdateRegistrationPaymentStatusOutput, error) { + query, err := schema.ReadSQLBaseScript("registration/sql/update_payment_status.sql") + if err != nil { + errr := errs.InternalServerError("Failed to read base query: ", err.Error()) + return nil, &errr + } + + row := r.db.QueryRow(ctx, query, input.ID, input.Body.PaymentIntentStatus) + + var output models.UpdateRegistrationPaymentStatusOutput + + err = row.Scan( + &output.Body.ID, + &output.Body.ChildID, + &output.Body.GuardianID, + &output.Body.EventOccurrenceID, + &output.Body.Status, + &output.Body.CreatedAt, + &output.Body.UpdatedAt, + &output.Body.StripeCustomerID, + &output.Body.OrgStripeAccountID, + &output.Body.Currency, + &output.Body.PaymentIntentStatus, + &output.Body.CancelledAt, + &output.Body.StripePaymentIntentID, + &output.Body.TotalAmount, + &output.Body.ProviderAmount, + &output.Body.PlatformFeeAmount, + &output.Body.PaidAt, + &output.Body.StripePaymentMethodID, + &output.Body.EventName, + &output.Body.OccurrenceStartTime, + ) + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + errr := errs.NotFound("Registration", "id", input.ID) + return nil, &errr + } + errr := errs.InternalServerError("Failed to update payment status: ", err.Error()) + return nil, &errr + } + + return &output, nil +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/update_payment_status_test.go b/backend/internal/storage/postgres/schema/registration/update_payment_status_test.go new file mode 100644 index 00000000..b0086d20 --- /dev/null +++ b/backend/internal/storage/postgres/schema/registration/update_payment_status_test.go @@ -0,0 +1,266 @@ +package registration + +import ( + "context" + "skillspark/internal/models" + "skillspark/internal/storage/postgres/testutil" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateRegistrationPaymentStatus(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + require.Equal(t, "requires_capture", created.PaymentIntentStatus) + require.Nil(t, created.PaidAt) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "succeeded" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, created.ID, updated.Body.ID) + assert.Equal(t, "succeeded", updated.Body.PaymentIntentStatus) + assert.NotNil(t, updated.Body.PaidAt) + assert.NotZero(t, updated.Body.PaidAt) + + assert.Equal(t, created.ChildID, updated.Body.ChildID) + assert.Equal(t, created.GuardianID, updated.Body.GuardianID) + assert.Equal(t, created.EventOccurrenceID, updated.Body.EventOccurrenceID) + assert.Equal(t, created.Status, updated.Body.Status) + assert.Equal(t, created.StripePaymentIntentID, updated.Body.StripePaymentIntentID) + assert.Equal(t, created.TotalAmount, updated.Body.TotalAmount) + assert.Equal(t, created.Currency, updated.Body.Currency) + assert.NotEmpty(t, updated.Body.EventName) + assert.NotZero(t, updated.Body.OccurrenceStartTime) +} + +func TestUpdateRegistrationPaymentStatus_RequiresCapture(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "requires_capture" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, "requires_capture", updated.Body.PaymentIntentStatus) + assert.Nil(t, updated.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_Processing(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "processing" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, "processing", updated.Body.PaymentIntentStatus) + assert.Nil(t, updated.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_Canceled(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "canceled" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, "canceled", updated.Body.PaymentIntentStatus) + assert.Nil(t, updated.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_RequiresPaymentMethod(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "requires_payment_method" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + + require.Nil(t, err) + require.NotNil(t, updated) + assert.Equal(t, "requires_payment_method", updated.Body.PaymentIntentStatus) + assert.Nil(t, updated.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_NotFound(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + nonExistentID := uuid.New() + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: nonExistentID, + } + input.Body.PaymentIntentStatus = "succeeded" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + + require.NotNil(t, err) + assert.Nil(t, updated) +} + +func TestUpdateRegistrationPaymentStatus_VerifyPersistence(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "succeeded" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + require.Nil(t, err) + + getInput := &models.GetRegistrationByIDInput{ + ID: created.ID, + } + fetched, err := repo.GetRegistrationByID(ctx, getInput) + + require.Nil(t, err) + require.NotNil(t, fetched) + assert.Equal(t, "succeeded", fetched.Body.PaymentIntentStatus) + assert.NotNil(t, fetched.Body.PaidAt) + assert.Equal(t, updated.Body.PaidAt, fetched.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_MultipleUpdates(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + input1 := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input1.Body.PaymentIntentStatus = "processing" + + updated1, err := repo.UpdateRegistrationPaymentStatus(ctx, input1) + require.Nil(t, err) + assert.Equal(t, "processing", updated1.Body.PaymentIntentStatus) + assert.Nil(t, updated1.Body.PaidAt) + + input2 := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input2.Body.PaymentIntentStatus = "succeeded" + + updated2, err := repo.UpdateRegistrationPaymentStatus(ctx, input2) + require.Nil(t, err) + assert.Equal(t, "succeeded", updated2.Body.PaymentIntentStatus) + assert.NotNil(t, updated2.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_PaidAtOnlySetOnSuccess(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + + statuses := []string{"processing", "requires_payment_method", "requires_capture", "canceled"} + + for _, status := range statuses { + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = status + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + require.Nil(t, err) + assert.Equal(t, status, updated.Body.PaymentIntentStatus) + assert.Nil(t, updated.Body.PaidAt, "paid_at should be nil for status: %s", status) + } + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "succeeded" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + require.Nil(t, err) + assert.Equal(t, "succeeded", updated.Body.PaymentIntentStatus) + assert.NotNil(t, updated.Body.PaidAt) +} + +func TestUpdateRegistrationPaymentStatus_DoesNotAffectCancellation(t *testing.T) { + testDB := testutil.SetupTestDB(t) + repo := NewRegistrationRepository(testDB) + ctx := context.Background() + t.Parallel() + + created := CreateTestRegistration(t, ctx, testDB) + cancelInput := &models.CancelRegistrationInput{ + ID: created.ID, + } + _, err := repo.CancelRegistration(ctx, cancelInput) + require.Nil(t, err) + + input := &models.UpdateRegistrationPaymentStatusInput{ + ID: created.ID, + } + input.Body.PaymentIntentStatus = "succeeded" + + updated, err := repo.UpdateRegistrationPaymentStatus(ctx, input) + require.Nil(t, err) + assert.Equal(t, models.RegistrationStatusCancelled, updated.Body.Status) + assert.NotNil(t, updated.Body.CancelledAt) + assert.Equal(t, "succeeded", updated.Body.PaymentIntentStatus) + assert.NotNil(t, updated.Body.PaidAt) +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/update_test.go b/backend/internal/storage/postgres/schema/registration/update_test.go index fcce84bf..63e5ad3d 100644 --- a/backend/internal/storage/postgres/schema/registration/update_test.go +++ b/backend/internal/storage/postgres/schema/registration/update_test.go @@ -20,79 +20,95 @@ func TestUpdateRegistration(t *testing.T) { ctx := context.Background() childID := child.CreateTestChild(t, ctx, testDB).ID + newChildID := child.CreateTestChild(t, ctx, testDB).ID guardianID := guardian.CreateTestGuardian(t, ctx, testDB).ID occurrenceID := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB).ID - createInput := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = occurrenceID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + createInput := &models.CreateRegistrationWithPaymentData{ + ChildID: childID, + GuardianID: guardianID, + EventOccurrenceID: occurrenceID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created, createErr := repo.CreateRegistration(ctx, createInput) require.Nil(t, createErr) require.NotNil(t, created) - newStatus := models.RegistrationStatusCancelled updateInput := &models.UpdateRegistrationInput{ ID: created.Body.ID, } - updateInput.Body.Status = &newStatus + updateInput.Body.ChildID = newChildID updated, updateErr := repo.UpdateRegistration(ctx, updateInput) require.Nil(t, updateErr) require.NotNil(t, updated) - assert.Equal(t, models.RegistrationStatusCancelled, updated.Body.Status) - assert.Equal(t, created.Body.ChildID, updated.Body.ChildID) + assert.Equal(t, newChildID, updated.Body.ChildID) assert.Equal(t, created.Body.GuardianID, updated.Body.GuardianID) assert.Equal(t, created.Body.EventOccurrenceID, updated.Body.EventOccurrenceID) + assert.Equal(t, created.Body.Status, updated.Body.Status) assert.NotEmpty(t, updated.Body.EventName) assert.NotZero(t, updated.Body.OccurrenceStartTime) + // Verify payment fields remain unchanged + assert.Equal(t, created.Body.StripePaymentIntentID, updated.Body.StripePaymentIntentID) + assert.Equal(t, created.Body.TotalAmount, updated.Body.TotalAmount) + assert.Equal(t, created.Body.Currency, updated.Body.Currency) getInput := &models.GetRegistrationByIDInput{ ID: created.Body.ID, } fetched, getErr := repo.GetRegistrationByID(ctx, getInput) require.Nil(t, getErr) - assert.Equal(t, models.RegistrationStatusCancelled, fetched.Body.Status) + assert.Equal(t, newChildID, fetched.Body.ChildID) } -func TestUpdateRegistration_ChangeEventOccurrence(t *testing.T) { +func TestUpdateRegistration_InvalidChildID(t *testing.T) { testDB := testutil.SetupTestDB(t) repo := NewRegistrationRepository(testDB) ctx := context.Background() childID := child.CreateTestChild(t, ctx, testDB).ID guardianID := guardian.CreateTestGuardian(t, ctx, testDB).ID - occurrenceID1 := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB).ID + occurrenceID := eventoccurrence.CreateTestEventOccurrence(t, ctx, testDB).ID - createInput := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = childID - i.Body.GuardianID = guardianID - i.Body.EventOccurrenceID = occurrenceID1 - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + createInput := &models.CreateRegistrationWithPaymentData{ + ChildID: childID, + GuardianID: guardianID, + EventOccurrenceID: occurrenceID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_123", + StripeCustomerID: "cus_test_123", + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, + ProviderAmount: 8500, + PlatformFeeAmount: 1500, + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } created, createErr := repo.CreateRegistration(ctx, createInput) require.Nil(t, createErr) require.NotNil(t, created) - newOccurrenceID := uuid.MustParse("70000000-0000-0000-0000-000000000003") + invalidChildID := uuid.New() updateInput := &models.UpdateRegistrationInput{ ID: created.Body.ID, } - updateInput.Body.EventOccurrenceID = &newOccurrenceID + updateInput.Body.ChildID = invalidChildID updated, updateErr := repo.UpdateRegistration(ctx, updateInput) - require.Nil(t, updateErr) - require.NotNil(t, updated) - assert.Equal(t, newOccurrenceID, updated.Body.EventOccurrenceID) - assert.NotEmpty(t, updated.Body.EventName) + require.NotNil(t, updateErr) + assert.Nil(t, updated) } func TestUpdateRegistration_NotFound(t *testing.T) { @@ -101,14 +117,15 @@ func TestUpdateRegistration_NotFound(t *testing.T) { ctx := context.Background() nonExistentID := uuid.New() - newStatus := models.RegistrationStatusCancelled + childID := child.CreateTestChild(t, ctx, testDB).ID + updateInput := &models.UpdateRegistrationInput{ ID: nonExistentID, } - updateInput.Body.Status = &newStatus + updateInput.Body.ChildID = childID updated, err := repo.UpdateRegistration(ctx, updateInput) require.NotNil(t, err) assert.Nil(t, updated) -} +} \ No newline at end of file diff --git a/backend/internal/storage/postgres/schema/registration/util.go b/backend/internal/storage/postgres/schema/registration/util.go index dd7d374a..811e73d4 100644 --- a/backend/internal/storage/postgres/schema/registration/util.go +++ b/backend/internal/storage/postgres/schema/registration/util.go @@ -23,14 +23,21 @@ func CreateTestRegistration( child := child.CreateTestChild(t, ctx, db) occurrence := eventoccurrence.CreateTestEventOccurrence(t, ctx, db) - input := func() *models.CreateRegistrationInput { - i := &models.CreateRegistrationInput{} - i.Body.ChildID = child.ID - i.Body.GuardianID = child.GuardianID - i.Body.EventOccurrenceID = occurrence.ID - i.Body.Status = models.RegistrationStatusRegistered - return i - }() + input := &models.CreateRegistrationWithPaymentData{ + ChildID: child.ID, + GuardianID: child.GuardianID, + EventOccurrenceID: occurrence.ID, + Status: models.RegistrationStatusRegistered, + StripePaymentIntentID: "pi_test_" + child.ID.String()[:8], + StripeCustomerID: "cus_test_" + child.GuardianID.String()[:8], + OrgStripeAccountID: "acct_test_123", + StripePaymentMethodID: "pm_test_123", + TotalAmount: 10000, // $100.00 + ProviderAmount: 8500, // $85.00 + PlatformFeeAmount: 1500, // $15.00 + Currency: "usd", + PaymentIntentStatus: "requires_capture", + } registration, err := repo.CreateRegistration(ctx, input) @@ -38,4 +45,4 @@ func CreateTestRegistration( require.NotNil(t, registration.Body) return ®istration.Body -} +} \ No newline at end of file diff --git a/backend/internal/storage/repo-mocks/guardianMock.go b/backend/internal/storage/repo-mocks/guardianMock.go index 35ea4ebe..6aa1632e 100644 --- a/backend/internal/storage/repo-mocks/guardianMock.go +++ b/backend/internal/storage/repo-mocks/guardianMock.go @@ -89,3 +89,14 @@ func (m *MockGuardianRepository) GetGuardianByAuthID(ctx context.Context, authID } return args.Get(0).(*models.Guardian), nil } + +func (m *MockGuardianRepository) SetStripeCustomerID(ctx context.Context, guardianID uuid.UUID, stripeCustomerID string) (*models.Guardian, error) { + args := m.Called(ctx, guardianID, stripeCustomerID) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).(*models.Guardian), nil +} \ No newline at end of file diff --git a/backend/internal/storage/repo-mocks/guardianPaymentMethod.go b/backend/internal/storage/repo-mocks/guardianPaymentMethod.go new file mode 100644 index 00000000..7e88076c --- /dev/null +++ b/backend/internal/storage/repo-mocks/guardianPaymentMethod.go @@ -0,0 +1,71 @@ +package repomocks + +import ( + "context" + "skillspark/internal/errs" + "skillspark/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/mock" +) + +type MockGuardianPaymentMethodRepository struct { + mock.Mock +} + +func (m *MockGuardianPaymentMethodRepository) CreateGuardianPaymentMethod( + ctx context.Context, + input *models.CreateGuardianPaymentMethodInput, +) (*models.GuardianPaymentMethod, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).(*models.GuardianPaymentMethod), nil +} + +func (m *MockGuardianPaymentMethodRepository) GetPaymentMethodsByGuardianID( + ctx context.Context, + guardianID uuid.UUID, +) ([]models.GuardianPaymentMethod, error) { + args := m.Called(ctx, guardianID) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).([]models.GuardianPaymentMethod), nil +} + +func (m *MockGuardianPaymentMethodRepository) UpdateGuardianPaymentMethod( + ctx context.Context, + id uuid.UUID, + isDefault bool, +) (*models.GuardianPaymentMethod, error) { + args := m.Called(ctx, id, isDefault) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).(*models.GuardianPaymentMethod), nil +} + +func (m *MockGuardianPaymentMethodRepository) DeleteGuardianPaymentMethod( + ctx context.Context, + id uuid.UUID, +) (*models.GuardianPaymentMethod, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).(*models.GuardianPaymentMethod), nil +} \ No newline at end of file diff --git a/backend/internal/storage/repo-mocks/locationMock.go b/backend/internal/storage/repo-mocks/locationMock.go index 8376d142..f09e83f9 100644 --- a/backend/internal/storage/repo-mocks/locationMock.go +++ b/backend/internal/storage/repo-mocks/locationMock.go @@ -24,6 +24,17 @@ func (m *MockLocationRepository) GetLocationByID(ctx context.Context, id uuid.UU return args.Get(0).(*models.Location), nil } +func (m *MockLocationRepository) GetLocationByOrganizationID(ctx context.Context, orgID uuid.UUID) (*models.Location, error) { + args := m.Called(ctx, orgID) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(error) + } + return args.Get(0).(*models.Location), nil +} + func (m *MockLocationRepository) CreateLocation(ctx context.Context, location *models.CreateLocationInput) (*models.Location, error) { args := m.Called(ctx, location) if args.Get(0) == nil { diff --git a/backend/internal/storage/repo-mocks/organizationMock.go b/backend/internal/storage/repo-mocks/organizationMock.go index cc95fcc9..67cca8b3 100644 --- a/backend/internal/storage/repo-mocks/organizationMock.go +++ b/backend/internal/storage/repo-mocks/organizationMock.go @@ -80,3 +80,25 @@ func (m *MockOrganizationRepository) GetEventOccurrencesByOrganizationID(ctx con } return eventOccurrences.([]models.EventOccurrence), nil } + +func (m *MockOrganizationRepository) SetStripeAccountID(ctx context.Context, orgID uuid.UUID, stripeAccountID string) (*models.Organization, error) { + args := m.Called(ctx, orgID, stripeAccountID) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).(*models.Organization), nil +} + +func (m *MockOrganizationRepository) SetStripeAccountActivated(ctx context.Context, stripeAccountID string, activated bool) (*models.Organization, error) { + args := m.Called(ctx, stripeAccountID, activated) + if args.Get(0) == nil { + if args.Get(1) == nil { + return nil, nil + } + return nil, args.Get(1).(*errs.HTTPError) + } + return args.Get(0).(*models.Organization), nil +} \ No newline at end of file diff --git a/backend/internal/storage/repo-mocks/registrationMock.go b/backend/internal/storage/repo-mocks/registrationMock.go index ae10688a..9df1080f 100644 --- a/backend/internal/storage/repo-mocks/registrationMock.go +++ b/backend/internal/storage/repo-mocks/registrationMock.go @@ -12,7 +12,7 @@ type MockRegistrationRepository struct { mock.Mock } -func (m *MockRegistrationRepository) CreateRegistration(ctx context.Context, input *models.CreateRegistrationInput) (*models.CreateRegistrationOutput, error) { +func (m *MockRegistrationRepository) CreateRegistration(ctx context.Context, input *models.CreateRegistrationWithPaymentData) (*models.CreateRegistrationOutput, error) { args := m.Called(ctx, input) if args.Get(0) == nil { return nil, args.Error(1) @@ -60,10 +60,26 @@ func (m *MockRegistrationRepository) GetRegistrationsByEventOccurrenceID(ctx con return args.Get(0).(*models.GetRegistrationsByEventOccurrenceIDOutput), args.Error(1) } +func (m *MockRegistrationRepository) CancelRegistration(ctx context.Context, input *models.CancelRegistrationInput) (*models.CancelRegistrationOutput, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CancelRegistrationOutput), args.Error(1) +} + +func (m *MockRegistrationRepository) UpdateRegistrationPaymentStatus(ctx context.Context, input *models.UpdateRegistrationPaymentStatusInput) (*models.UpdateRegistrationPaymentStatusOutput, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.UpdateRegistrationPaymentStatusOutput), args.Error(1) +} + func (m *MockEventOccurrenceRepository) DeleteEventOccurrence( ctx context.Context, id uuid.UUID, ) error { args := m.Called(ctx, id) return args.Error(0) -} +} \ No newline at end of file diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index 39736a0f..e1e13948 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -7,6 +7,7 @@ import ( "skillspark/internal/storage/postgres/schema/event" eventoccurrence "skillspark/internal/storage/postgres/schema/event-occurrence" "skillspark/internal/storage/postgres/schema/guardian" + guardianpaymentmethod "skillspark/internal/storage/postgres/schema/guardian-payment-method" "skillspark/internal/storage/postgres/schema/location" "skillspark/internal/storage/postgres/schema/manager" "skillspark/internal/storage/postgres/schema/organization" @@ -25,6 +26,7 @@ type LocationRepository interface { GetLocationByID(ctx context.Context, id uuid.UUID) (*models.Location, error) CreateLocation(ctx context.Context, location *models.CreateLocationInput) (*models.Location, error) GetAllLocations(ctx context.Context, pagination utils.Pagination) ([]models.Location, error) + GetLocationByOrganizationID(ctx context.Context, orgID uuid.UUID) (*models.Location, error) } type SchoolRepository interface { @@ -39,6 +41,8 @@ type OrganizationRepository interface { UpdateOrganization(ctx context.Context, org *models.UpdateOrganizationInput, PfpS3Key *string) (*models.Organization, error) DeleteOrganization(ctx context.Context, id uuid.UUID) (*models.Organization, error) GetEventOccurrencesByOrganizationID(ctx context.Context, organization_id uuid.UUID) ([]models.EventOccurrence, error) + SetStripeAccountID(ctx context.Context, orgID uuid.UUID, stripeAccountID string) (*models.Organization, error) + SetStripeAccountActivated(ctx context.Context, stripeAccountID string, activated bool) (*models.Organization, error) } type ManagerRepository interface { @@ -59,6 +63,14 @@ type GuardianRepository interface { GetGuardianByAuthID(ctx context.Context, authID string) (*models.Guardian, error) UpdateGuardian(ctx context.Context, guardian *models.UpdateGuardianInput) (*models.Guardian, error) DeleteGuardian(ctx context.Context, id uuid.UUID) (*models.Guardian, error) + SetStripeCustomerID(ctx context.Context, guardianID uuid.UUID, stripeCustomerID string,) (*models.Guardian, error) +} + +type GuardianPaymentMethodRepository interface { + CreateGuardianPaymentMethod(ctx context.Context, input *models.CreateGuardianPaymentMethodInput) (*models.GuardianPaymentMethod, error) + GetPaymentMethodsByGuardianID(ctx context.Context, guardianID uuid.UUID) ([]models.GuardianPaymentMethod, error) + UpdateGuardianPaymentMethod(ctx context.Context, id uuid.UUID, isDefault bool) (*models.GuardianPaymentMethod, error) + DeleteGuardianPaymentMethod(ctx context.Context, id uuid.UUID) (*models.GuardianPaymentMethod, error) } type EventRepository interface { @@ -86,13 +98,16 @@ type EventOccurrenceRepository interface { } type RegistrationRepository interface { - CreateRegistration(ctx context.Context, input *models.CreateRegistrationInput) (*models.CreateRegistrationOutput, error) + CreateRegistration(ctx context.Context, input *models.CreateRegistrationWithPaymentData) (*models.CreateRegistrationOutput, error) GetRegistrationByID(ctx context.Context, input *models.GetRegistrationByIDInput) (*models.GetRegistrationByIDOutput, error) GetRegistrationsByChildID(ctx context.Context, input *models.GetRegistrationsByChildIDInput) (*models.GetRegistrationsByChildIDOutput, error) GetRegistrationsByGuardianID(ctx context.Context, input *models.GetRegistrationsByGuardianIDInput) (*models.GetRegistrationsByGuardianIDOutput, error) GetRegistrationsByEventOccurrenceID(ctx context.Context, input *models.GetRegistrationsByEventOccurrenceIDInput) (*models.GetRegistrationsByEventOccurrenceIDOutput, error) UpdateRegistration(ctx context.Context, input *models.UpdateRegistrationInput) (*models.UpdateRegistrationOutput, error) + CancelRegistration(ctx context.Context, input *models.CancelRegistrationInput) (*models.CancelRegistrationOutput, error) + UpdateRegistrationPaymentStatus(ctx context.Context, input *models.UpdateRegistrationPaymentStatusInput) (*models.UpdateRegistrationPaymentStatusOutput, error) } + type Repository struct { db *pgxpool.Pool Location LocationRepository @@ -105,6 +120,7 @@ type Repository struct { EventOccurrence EventOccurrenceRepository Registration RegistrationRepository User UserRepository + GuardianPaymentMethod GuardianPaymentMethodRepository } type UserRepository interface { @@ -139,5 +155,6 @@ func NewRepository(db *pgxpool.Pool) *Repository { EventOccurrence: eventoccurrence.NewEventOccurrenceRepository(db), User: user.NewUserRepository(db), Registration: registration.NewRegistrationRepository(db), + GuardianPaymentMethod: guardianpaymentmethod.NewGuardianPaymentMethodRepository(db), } -} +} \ No newline at end of file diff --git a/backend/internal/stripeClient/cancelPaymentIntent.go b/backend/internal/stripeClient/cancelPaymentIntent.go new file mode 100644 index 00000000..659105e0 --- /dev/null +++ b/backend/internal/stripeClient/cancelPaymentIntent.go @@ -0,0 +1,26 @@ +package stripeClient + +import ( + "context" + "skillspark/internal/models" + + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) CancelPaymentIntent(ctx context.Context, input *models.CancelPaymentIntentInput) (*models.CancelPaymentIntentOutput, error) { + params := &stripe.PaymentIntentCancelParams{} + params.SetStripeAccount(input.StripeAccountID) + + pi, err := sc.client.V1PaymentIntents.Cancel(ctx, input.PaymentIntentID, params) + if err != nil { + return nil, err + } + + output := &models.CancelPaymentIntentOutput{} + output.Body.PaymentIntentID = pi.ID + output.Body.Status = string(pi.Status) + output.Body.Amount = pi.Amount + output.Body.Currency = string(pi.Currency) + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/cancelPaymentIntent_test.go b/backend/internal/stripeClient/cancelPaymentIntent_test.go new file mode 100644 index 00000000..3181d197 --- /dev/null +++ b/backend/internal/stripeClient/cancelPaymentIntent_test.go @@ -0,0 +1,153 @@ +package stripeClient + +import ( + "context" + "skillspark/internal/models" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripeClient_CancelPaymentIntent_RequiresCapture(t *testing.T) { + t.Skip("Requires Express account with transfer capability - use handler mocks instead") + + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + paymentMethodID := "pm_card_visa" + + createPIInput := &models.CreatePaymentIntentInput{} + createPIInput.Body.Amount = 10000 + createPIInput.Body.Currency = "usd" + createPIInput.Body.GuardianStripeID = testStripeCustomerID + createPIInput.Body.OrgStripeID = testStripeAccountID + createPIInput.Body.PaymentMethodID = &paymentMethodID + + createdPI, err := client.CreatePaymentIntent(ctx, createPIInput) + require.NoError(t, err) + require.Equal(t, "requires_capture", createdPI.Body.Status) + + cancelInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + + cancelled, err := client.CancelPaymentIntent(ctx, cancelInput) + + require.NoError(t, err) + require.NotNil(t, cancelled) + assert.Equal(t, createdPI.Body.PaymentIntentID, cancelled.Body.PaymentIntentID) + assert.Equal(t, "canceled", cancelled.Body.Status) + assert.Equal(t, int64(10000), cancelled.Body.Amount) + assert.Equal(t, "usd", cancelled.Body.Currency) +} + +func TestStripeClient_CancelPaymentIntent_Succeeded(t *testing.T) { + t.Skip("Requires Express account with transfer capability - use handler mocks instead") + + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + paymentMethodID := "pm_card_visa" + + createPIInput := &models.CreatePaymentIntentInput{} + createPIInput.Body.Amount = 10000 + createPIInput.Body.Currency = "usd" + createPIInput.Body.GuardianStripeID = testStripeCustomerID + createPIInput.Body.OrgStripeID = testStripeAccountID + createPIInput.Body.PaymentMethodID = &paymentMethodID + + createdPI, err := client.CreatePaymentIntent(ctx, createPIInput) + require.NoError(t, err) + + captureInput := &models.CapturePaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + captured, err := client.CapturePaymentIntent(ctx, captureInput) + require.NoError(t, err) + require.Equal(t, "succeeded", captured.Body.Status) + + cancelInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + + cancelled, err := client.CancelPaymentIntent(ctx, cancelInput) + + require.NoError(t, err) + require.NotNil(t, cancelled) + assert.Equal(t, createdPI.Body.PaymentIntentID, cancelled.Body.PaymentIntentID) + assert.Equal(t, "succeeded", cancelled.Body.Status) + assert.Equal(t, int64(10000), cancelled.Body.Amount) + assert.Equal(t, "usd", cancelled.Body.Currency) +} + +func TestStripeClient_CancelPaymentIntent_InvalidID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + cancelInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: "pi_invalid_id", + StripeAccountID: testStripeAccountID, + } + + cancelled, err := client.CancelPaymentIntent(ctx, cancelInput) + + require.Error(t, err) + assert.Nil(t, cancelled) +} + +func TestStripeClient_CancelPaymentIntent_AlreadyCanceled(t *testing.T) { + t.Skip("Requires Express account with transfer capability - use handler mocks instead") + + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + paymentMethodID := "pm_card_visa" + + createPIInput := &models.CreatePaymentIntentInput{} + createPIInput.Body.Amount = 10000 + createPIInput.Body.Currency = "usd" + createPIInput.Body.GuardianStripeID = testStripeCustomerID + createPIInput.Body.OrgStripeID = testStripeAccountID + createPIInput.Body.PaymentMethodID = &paymentMethodID + + createdPI, err := client.CreatePaymentIntent(ctx, createPIInput) + require.NoError(t, err) + + cancelInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + + cancelled1, err := client.CancelPaymentIntent(ctx, cancelInput) + require.NoError(t, err) + require.Equal(t, "canceled", cancelled1.Body.Status) + + cancelled2, err := client.CancelPaymentIntent(ctx, cancelInput) + + require.Error(t, err) + assert.Nil(t, cancelled2) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/capturePaymentIntent.go b/backend/internal/stripeClient/capturePaymentIntent.go new file mode 100644 index 00000000..dceff452 --- /dev/null +++ b/backend/internal/stripeClient/capturePaymentIntent.go @@ -0,0 +1,26 @@ +package stripeClient + +import ( + "context" + "skillspark/internal/models" + + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) CapturePaymentIntent(ctx context.Context, input *models.CapturePaymentIntentInput) (*models.CapturePaymentIntentOutput, error) { + params := &stripe.PaymentIntentCaptureParams{} + params.SetStripeAccount(input.StripeAccountID) + + pi, err := sc.client.V1PaymentIntents.Capture(ctx, input.PaymentIntentID, params) + if err != nil { + return nil, err + } + + output := &models.CapturePaymentIntentOutput{} + output.Body.PaymentIntentID = pi.ID + output.Body.Status = string(pi.Status) + output.Body.Amount = pi.Amount + output.Body.Currency = string(pi.Currency) + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/capturePaymentIntent_test.go b/backend/internal/stripeClient/capturePaymentIntent_test.go new file mode 100644 index 00000000..83abf3d4 --- /dev/null +++ b/backend/internal/stripeClient/capturePaymentIntent_test.go @@ -0,0 +1,151 @@ +package stripeClient + +import ( + "context" + "skillspark/internal/models" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testStripeAccountID = "acct_1T0lX12SjspRdSkp" +const testStripeCustomerID = "cus_TwTqKNe9HwjesR" + +func TestStripeClient_CapturePaymentIntent_Success(t *testing.T) { + t.Skip("Requires Express account with transfer capability - use handler mocks instead") + + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + paymentMethodID := "pm_card_visa" + + createPIInput := &models.CreatePaymentIntentInput{} + createPIInput.Body.Amount = 10000 + createPIInput.Body.Currency = "usd" + createPIInput.Body.GuardianStripeID = testStripeCustomerID + createPIInput.Body.OrgStripeID = testStripeAccountID + createPIInput.Body.PaymentMethodID = &paymentMethodID + + createdPI, err := client.CreatePaymentIntent(ctx, createPIInput) + require.NoError(t, err) + require.Equal(t, "requires_capture", createdPI.Body.Status) + + captureInput := &models.CapturePaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + + captured, err := client.CapturePaymentIntent(ctx, captureInput) + + require.NoError(t, err) + require.NotNil(t, captured) + assert.Equal(t, createdPI.Body.PaymentIntentID, captured.Body.PaymentIntentID) + assert.Equal(t, "succeeded", captured.Body.Status) + assert.Equal(t, int64(10000), captured.Body.Amount) + assert.Equal(t, "usd", captured.Body.Currency) +} + +func TestStripeClient_CapturePaymentIntent_InvalidID(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + captureInput := &models.CapturePaymentIntentInput{ + PaymentIntentID: "pi_invalid_id", + StripeAccountID: testStripeAccountID, + } + + captured, err := client.CapturePaymentIntent(ctx, captureInput) + + require.Error(t, err) + assert.Nil(t, captured) +} + +func TestStripeClient_CapturePaymentIntent_AlreadyCaptured(t *testing.T) { + t.Skip("Requires Express account with transfer capability - use handler mocks instead") + + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + paymentMethodID := "pm_card_visa" + + createPIInput := &models.CreatePaymentIntentInput{} + createPIInput.Body.Amount = 10000 + createPIInput.Body.Currency = "usd" + createPIInput.Body.GuardianStripeID = testStripeCustomerID + createPIInput.Body.OrgStripeID = testStripeAccountID + createPIInput.Body.PaymentMethodID = &paymentMethodID + + createdPI, err := client.CreatePaymentIntent(ctx, createPIInput) + require.NoError(t, err) + + captureInput := &models.CapturePaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + + captured1, err := client.CapturePaymentIntent(ctx, captureInput) + require.NoError(t, err) + require.Equal(t, "succeeded", captured1.Body.Status) + + captured2, err := client.CapturePaymentIntent(ctx, captureInput) + + require.Error(t, err) + assert.Nil(t, captured2) +} + +func TestStripeClient_CapturePaymentIntent_CanceledIntent(t *testing.T) { + t.Skip("Requires Express account with transfer capability - use handler mocks instead") + + if testing.Short() { + t.Skip("Skipping Stripe integration test") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + paymentMethodID := "pm_card_visa" + + createPIInput := &models.CreatePaymentIntentInput{} + createPIInput.Body.Amount = 10000 + createPIInput.Body.Currency = "usd" + createPIInput.Body.GuardianStripeID = testStripeCustomerID + createPIInput.Body.OrgStripeID = testStripeAccountID + createPIInput.Body.PaymentMethodID = &paymentMethodID + + createdPI, err := client.CreatePaymentIntent(ctx, createPIInput) + require.NoError(t, err) + + cancelInput := &models.CancelPaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + _, err = client.CancelPaymentIntent(ctx, cancelInput) + require.NoError(t, err) + + captureInput := &models.CapturePaymentIntentInput{ + PaymentIntentID: createdPI.Body.PaymentIntentID, + StripeAccountID: testStripeAccountID, + } + + captured, err := client.CapturePaymentIntent(ctx, captureInput) + + require.Error(t, err) + assert.Nil(t, captured) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createCustomer.go b/backend/internal/stripeClient/createCustomer.go new file mode 100644 index 00000000..e08474e0 --- /dev/null +++ b/backend/internal/stripeClient/createCustomer.go @@ -0,0 +1,26 @@ +package stripeClient + +import ( + "context" + + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) CreateCustomer( + ctx context.Context, + email string, + name string, +) (*stripe.Customer, error) { + params := &stripe.CustomerCreateParams{ + Email: stripe.String(email), + Name: stripe.String(name), + } + params.Context = ctx + + customer, err := sc.client.V1Customers.Create(ctx, params) + if err != nil { + return nil, err + } + + return customer, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createCustomerSetupIntent.go b/backend/internal/stripeClient/createCustomerSetupIntent.go new file mode 100644 index 00000000..40c8334f --- /dev/null +++ b/backend/internal/stripeClient/createCustomerSetupIntent.go @@ -0,0 +1,25 @@ +package stripeClient + +import ( + "context" + + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) CreateSetupIntent( + ctx context.Context, + stripeCustomerID string, +) (string, error) { + params := &stripe.SetupIntentCreateParams{ + Customer: stripe.String(stripeCustomerID), + Usage: stripe.String("off_session"), + PaymentMethodTypes: stripe.StringSlice([]string{"card"}), + } + + setupIntent, err := sc.client.V1SetupIntents.Create(ctx, params) + if err != nil { + return "", err + } + + return setupIntent.ClientSecret, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createCustomerSetupIntent_test.go b/backend/internal/stripeClient/createCustomerSetupIntent_test.go new file mode 100644 index 00000000..90aad4c5 --- /dev/null +++ b/backend/internal/stripeClient/createCustomerSetupIntent_test.go @@ -0,0 +1,83 @@ +package stripeClient + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripeClient_CreateSetupIntent(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + t.Run("Successfully creates setup intent for valid customer", func(t *testing.T) { + // First create a customer + customer, err := client.CreateCustomer(ctx, "setuptest@example.com", "Setup Test User") + require.NoError(t, err) + require.NotNil(t, customer) + + // Create setup intent for the customer + clientSecret, err := client.CreateSetupIntent(ctx, customer.ID) + + require.NoError(t, err) + assert.NotEmpty(t, clientSecret) + assert.Contains(t, clientSecret, "seti_") // Setup intent secret starts with seti_ + assert.Contains(t, clientSecret, "_secret_") // Contains _secret_ in the middle + + t.Logf("Created setup intent with client secret: %s", clientSecret[:20]+"...") + }) + + t.Run("Successfully creates multiple setup intents for same customer", func(t *testing.T) { + // Create a customer + customer, err := client.CreateCustomer(ctx, "multiple@example.com", "Multiple Setup User") + require.NoError(t, err) + + // Create first setup intent + clientSecret1, err := client.CreateSetupIntent(ctx, customer.ID) + require.NoError(t, err) + assert.NotEmpty(t, clientSecret1) + + // Create second setup intent for same customer + clientSecret2, err := client.CreateSetupIntent(ctx, customer.ID) + require.NoError(t, err) + assert.NotEmpty(t, clientSecret2) + + // Should be different setup intents + assert.NotEqual(t, clientSecret1, clientSecret2) + }) + + t.Run("Fails with invalid customer ID", func(t *testing.T) { + clientSecret, err := client.CreateSetupIntent(ctx, "cus_invalid123") + + assert.Error(t, err) + assert.Empty(t, clientSecret) + assert.Contains(t, err.Error(), "No such customer") + }) + + t.Run("Fails with empty customer ID", func(t *testing.T) { + clientSecret, err := client.CreateSetupIntent(ctx, "") + + assert.Error(t, err) + assert.Empty(t, clientSecret) + }) + + t.Run("Successfully creates setup intent for Thai customer", func(t *testing.T) { + // Create Thai customer + customer, err := client.CreateCustomer(ctx, "ผู้ปกครอง@example.com", "สมชาย ใจดี") + require.NoError(t, err) + + // Create setup intent + clientSecret, err := client.CreateSetupIntent(ctx, customer.ID) + + require.NoError(t, err) + assert.NotEmpty(t, clientSecret) + assert.Contains(t, clientSecret, "seti_") + }) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createCustomer_test.go b/backend/internal/stripeClient/createCustomer_test.go new file mode 100644 index 00000000..1acf0743 --- /dev/null +++ b/backend/internal/stripeClient/createCustomer_test.go @@ -0,0 +1,108 @@ +package stripeClient + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripeClient_CreateCustomer(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + t.Run("Successfully creates customer with email and name", func(t *testing.T) { + customer, err := client.CreateCustomer( + ctx, + "parent@example.com", + "John Doe", + ) + + require.NoError(t, err) + require.NotNil(t, customer) + assert.NotEmpty(t, customer.ID) + assert.Equal(t, "parent@example.com", customer.Email) + assert.Equal(t, "John Doe", customer.Name) + assert.Equal(t, "customer", customer.Object) + + t.Logf("Created test customer: %s", customer.ID) + }) + + t.Run("Successfully creates customer with Thai name", func(t *testing.T) { + customer, err := client.CreateCustomer( + ctx, + "สมชาย@example.com", + "สมชาย ใจดี", + ) + + require.NoError(t, err) + require.NotNil(t, customer) + assert.NotEmpty(t, customer.ID) + assert.Equal(t, "สมชาย@example.com", customer.Email) + assert.Equal(t, "สมชาย ใจดี", customer.Name) + }) + + t.Run("Successfully creates customer with email only", func(t *testing.T) { + customer, err := client.CreateCustomer( + ctx, + "emailonly@example.com", + "", + ) + + require.NoError(t, err) + require.NotNil(t, customer) + assert.NotEmpty(t, customer.ID) + assert.Equal(t, "emailonly@example.com", customer.Email) + assert.Empty(t, customer.Name) + }) + + t.Run("Fails with invalid email", func(t *testing.T) { + customer, err := client.CreateCustomer( + ctx, + "not-an-email", + "Jane Smith", + ) + + assert.Error(t, err) + assert.Nil(t, customer) + assert.Contains(t, err.Error(), "email") + }) + + t.Run("Successfully creates customer with duplicate email", func(t *testing.T) { + // Stripe allows duplicate emails (unlike accounts) + email := "duplicate@example.com" + + customer1, err := client.CreateCustomer(ctx, email, "First Customer") + require.NoError(t, err) + require.NotNil(t, customer1) + + customer2, err := client.CreateCustomer(ctx, email, "Second Customer") + require.NoError(t, err) + require.NotNil(t, customer2) + + // Different customer IDs even with same email + assert.NotEqual(t, customer1.ID, customer2.ID) + assert.Equal(t, email, customer1.Email) + assert.Equal(t, email, customer2.Email) + }) + + t.Run("Successfully creates customer with long name", func(t *testing.T) { + longName := "Somchai Withveryverylonglastnamethatexceedsthirtychars" + + customer, err := client.CreateCustomer( + ctx, + "longname@example.com", + longName, + ) + + require.NoError(t, err) + require.NotNil(t, customer) + assert.Equal(t, longName, customer.Name) + }) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createLoginLink.go b/backend/internal/stripeClient/createLoginLink.go new file mode 100644 index 00000000..55e36992 --- /dev/null +++ b/backend/internal/stripeClient/createLoginLink.go @@ -0,0 +1,24 @@ +package stripeClient + +import ( + "context" + "github.com/stripe/stripe-go/v84" + "github.com/stripe/stripe-go/v84/loginlink" +) + +func (sc *StripeClient) CreateLoginLink( + ctx context.Context, + accountID string, +) (string, error) { + params := &stripe.LoginLinkParams{ + Account: stripe.String(accountID), + } + params.Context = ctx + + link, err := loginlink.New(params) + if err != nil { + return "", err + } + + return link.URL, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createOnboardingLink.go b/backend/internal/stripeClient/createOnboardingLink.go new file mode 100644 index 00000000..8d7eeee7 --- /dev/null +++ b/backend/internal/stripeClient/createOnboardingLink.go @@ -0,0 +1,36 @@ +package stripeClient + +import ( + "context" + "skillspark/internal/models" + + "github.com/stripe/stripe-go/v84" +) + + + +func (sc *StripeClient) CreateAccountOnboardingLink( + ctx context.Context, + input *models.CreateStripeOnboardingLinkInput, +) (*models.CreateStripeOnboardingLinkOutput, error) { + + params := &stripe.AccountLinkCreateParams{ + Account: &input.Body.AccountID, + RefreshURL: &input.Body.RefreshURL, + ReturnURL: &input.Body.ReturnURL, + Type: stripe.String("account_onboarding"), + CollectionOptions: &stripe.AccountLinkCreateCollectionOptionsParams{ + Fields: stripe.String("eventually_due"), // collects details needed currently and ones that will be needed in the future + }, + } + + link, err := sc.client.V1AccountLinks.Create(ctx, params) + if err != nil { + return nil, err + } + + output := &models.CreateStripeOnboardingLinkOutput{} + output.Body.OnboardingURL = link.URL // Doubt we need the other fields for link + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createOnboardingLink_test.go b/backend/internal/stripeClient/createOnboardingLink_test.go new file mode 100644 index 00000000..fb51c327 --- /dev/null +++ b/backend/internal/stripeClient/createOnboardingLink_test.go @@ -0,0 +1,147 @@ +package stripeClient + +import ( + "context" + "testing" + + "skillspark/internal/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripeClient_CreateAccountOnboardingLink(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + t.Run("Successfully creates onboarding link for valid account", func(t *testing.T) { + // First create an account + account, err := client.CreateOrganizationAccount( + ctx, + "Onboarding Test Org", + "onboarding@example.com", + "TH", + ) + require.NoError(t, err) + require.NotNil(t, account) + + // Create onboarding link + input := &models.CreateStripeOnboardingLinkInput{} + input.Body.AccountID = account.Body.Account.ID + input.Body.RefreshURL = "http://localhost:8080/onboarding/refresh" + input.Body.ReturnURL = "http://localhost:8080/onboarding/success" + + output, err := client.CreateAccountOnboardingLink(ctx, input) + + require.NoError(t, err) + require.NotNil(t, output) + assert.NotEmpty(t, output.Body.OnboardingURL) + assert.Contains(t, output.Body.OnboardingURL, "https://connect.stripe.com") + assert.Contains(t, output.Body.OnboardingURL, account.Body.Account.ID) + + t.Logf("Created onboarding link: %s", output.Body.OnboardingURL) + }) + + t.Run("Successfully creates onboarding link with different URLs", func(t *testing.T) { + // Create account + account, err := client.CreateOrganizationAccount( + ctx, + "URL Test Org", + "urltest@example.com", + "US", + ) + require.NoError(t, err) + + // Create onboarding link with different URLs + input := &models.CreateStripeOnboardingLinkInput{} + input.Body.AccountID = account.Body.Account.ID + input.Body.RefreshURL = "https://furever.com/setup/retry" + input.Body.ReturnURL = "https://furever.com/dashboard" + + output, err := client.CreateAccountOnboardingLink(ctx, input) + + require.NoError(t, err) + assert.NotEmpty(t, output.Body.OnboardingURL) + }) + + t.Run("Can create multiple onboarding links for same account", func(t *testing.T) { + // Create account + account, err := client.CreateOrganizationAccount( + ctx, + "Multiple Links Org", + "multiplelinks@example.com", + "TH", + ) + require.NoError(t, err) + + input := &models.CreateStripeOnboardingLinkInput{} + input.Body.AccountID = account.Body.Account.ID + input.Body.RefreshURL = "http://localhost:8080/refresh" + input.Body.ReturnURL = "http://localhost:8080/return" + + // Create first link + output1, err := client.CreateAccountOnboardingLink(ctx, input) + require.NoError(t, err) + assert.NotEmpty(t, output1.Body.OnboardingURL) + + // Create second link for same account + output2, err := client.CreateAccountOnboardingLink(ctx, input) + require.NoError(t, err) + assert.NotEmpty(t, output2.Body.OnboardingURL) + + // Links should be different (each is single-use) + assert.NotEqual(t, output1.Body.OnboardingURL, output2.Body.OnboardingURL) + }) + + t.Run("Fails with invalid account ID", func(t *testing.T) { + input := &models.CreateStripeOnboardingLinkInput{} + input.Body.AccountID = "acct_invalid123" + input.Body.RefreshURL = "http://localhost:8080/refresh" + input.Body.ReturnURL = "http://localhost:8080/return" + + output, err := client.CreateAccountOnboardingLink(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + assert.Contains(t, err.Error(), "No such account") + }) + + t.Run("Fails with empty account ID", func(t *testing.T) { + input := &models.CreateStripeOnboardingLinkInput{} + input.Body.AccountID = "" + input.Body.RefreshURL = "http://localhost:8080/refresh" + input.Body.ReturnURL = "http://localhost:8080/return" + + output, err := client.CreateAccountOnboardingLink(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + }) + + t.Run("Fails with invalid refresh URL format", func(t *testing.T) { + // Create account + account, err := client.CreateOrganizationAccount( + ctx, + "Invalid URL Org", + "invalidurl@example.com", + "TH", + ) + require.NoError(t, err) + + input := &models.CreateStripeOnboardingLinkInput{} + input.Body.AccountID = account.Body.Account.ID + input.Body.RefreshURL = "not-a-valid-url" + input.Body.ReturnURL = "http://localhost:8080/return" + + output, err := client.CreateAccountOnboardingLink(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + assert.Contains(t, err.Error(), "url") + }) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createOrganizationAccount.go b/backend/internal/stripeClient/createOrganizationAccount.go new file mode 100644 index 00000000..751ede90 --- /dev/null +++ b/backend/internal/stripeClient/createOrganizationAccount.go @@ -0,0 +1,62 @@ +package stripeClient + +import ( + "context" + + "skillspark/internal/models" + + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) CreateOrganizationAccount( + ctx context.Context, name string, email string, country string) (*models.CreateOrgStripeAccountOutput, error) { + params := &stripe.V2CoreAccountCreateParams{ + Identity: &stripe.V2CoreAccountCreateIdentityParams{ + Country: stripe.String(country), + }, + DisplayName: stripe.String(name), + ContactEmail: stripe.String(email), + Configuration: &stripe.V2CoreAccountCreateConfigurationParams{ + Recipient: &stripe.V2CoreAccountCreateConfigurationRecipientParams{ + Capabilities: &stripe.V2CoreAccountCreateConfigurationRecipientCapabilitiesParams{ + StripeBalance: &stripe.V2CoreAccountCreateConfigurationRecipientCapabilitiesStripeBalanceParams{ + StripeTransfers: &stripe.V2CoreAccountCreateConfigurationRecipientCapabilitiesStripeBalanceStripeTransfersParams{ + Requested: stripe.Bool(true), + }, + }, + }, + }, + Merchant: &stripe.V2CoreAccountCreateConfigurationMerchantParams{ + Capabilities: &stripe.V2CoreAccountCreateConfigurationMerchantCapabilitiesParams{ + CardPayments: &stripe.V2CoreAccountCreateConfigurationMerchantCapabilitiesCardPaymentsParams{ + Requested: stripe.Bool(true), + }, + // Add PromptPay here later if still wanted + }, + }, + }, + Defaults: &stripe.V2CoreAccountCreateDefaultsParams{ + Responsibilities: &stripe.V2CoreAccountCreateDefaultsResponsibilitiesParams{ + LossesCollector: stripe.String("application"), + FeesCollector: stripe.String("application"), + }, + }, + Dashboard: stripe.String("express"), + Include: []*string{ + stripe.String("configuration.merchant"), + stripe.String("configuration.recipient"), + stripe.String("identity"), + stripe.String("defaults"), + }, + } + + acct, err := sc.client.V2CoreAccounts.Create(ctx, params) + if err != nil { + return nil, err + } + + output := &models.CreateOrgStripeAccountOutput{} + output.Body.Account = *acct + + return output, nil +} diff --git a/backend/internal/stripeClient/createOrganizationAccount_test.go b/backend/internal/stripeClient/createOrganizationAccount_test.go new file mode 100644 index 00000000..627f76b0 --- /dev/null +++ b/backend/internal/stripeClient/createOrganizationAccount_test.go @@ -0,0 +1,98 @@ +package stripeClient + +import ( + "context" + "os" + "testing" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripeClient_CreateOrganizationAccount(t *testing.T) { + t.Skip("V2 API account creation is slow and times out - skip for now") + + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + t.Run("Successfully creates Express account", func(t *testing.T) { + output, err := client.CreateOrganizationAccount( + ctx, + "Test Bangkok Soccer Academy", + "test+soccer@example.com", + "TH", + ) + + require.NoError(t, err) + require.NotNil(t, output) + assert.NotEmpty(t, output.Body.Account.ID) + assert.Equal(t, "express", string(output.Body.Account.Dashboard)) + assert.Equal(t, "test+soccer@example.com", output.Body.Account.ContactEmail) + assert.Equal(t, "Test Bangkok Soccer Academy", output.Body.Account.DisplayName) + + assert.NotNil(t, output.Body.Account.Configuration) + assert.NotNil(t, output.Body.Account.Configuration.Merchant) + assert.NotNil(t, output.Body.Account.Configuration.Recipient) + + merchantCaps := output.Body.Account.Configuration.Merchant.Capabilities + assert.NotNil(t, merchantCaps.CardPayments) + assert.NotEqual(t, "", merchantCaps.CardPayments.Status) + + t.Logf("Created test account: %s", output.Body.Account.ID) + }) + + t.Run("Successfully creates account for US organization", func(t *testing.T) { + output, err := client.CreateOrganizationAccount( + ctx, + "Test US Sports Center", + "test+us@example.com", + "US", + ) + + require.NoError(t, err) + require.NotNil(t, output) + assert.NotEmpty(t, output.Body.Account.ID) + assert.Equal(t, "US", output.Body.Account.Identity.Country) + }) + + t.Run("Fails with invalid country code", func(t *testing.T) { + output, err := client.CreateOrganizationAccount( + ctx, + "Test Invalid Country", + "test+invalid@example.com", + "INVALID", + ) + + assert.Error(t, err) + assert.Nil(t, output) + assert.Contains(t, err.Error(), "country") + }) + + t.Run("Fails with invalid email", func(t *testing.T) { + output, err := client.CreateOrganizationAccount( + ctx, + "Test Invalid Email", + "not-an-email", + "TH", + ) + + assert.Error(t, err) + assert.Nil(t, output) + }) +} + +func getTestStripeAPIKey(t *testing.T) string { + _ = godotenv.Load("../../.env") + + apiKey := os.Getenv("STRIPE_SECRET_TEST_KEY") + if apiKey == "" { + t.Skip("STRIPE_SECRET_TEST_KEY not set, skipping Stripe integration tests") + } + return apiKey +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createPaymentIntent.go b/backend/internal/stripeClient/createPaymentIntent.go new file mode 100644 index 00000000..7b0b8cf0 --- /dev/null +++ b/backend/internal/stripeClient/createPaymentIntent.go @@ -0,0 +1,67 @@ +package stripeClient + +import ( + "context" + "errors" + "skillspark/internal/models" + "time" + + "github.com/stripe/stripe-go/v84" +) + + + +func (sc *StripeClient) CreatePaymentIntent(ctx context.Context, input *models.CreatePaymentIntentInput) (*models.CreatePaymentIntentOutput, error) { + + const applicationFeePercentage = 10 // CHANGE THIS TO BE THE APPLICATION FEE PERCENTAGE + + applicationFeeTotal := (input.Body.Amount * int64(applicationFeePercentage)) / 100 + organizationProfit := input.Body.Amount - applicationFeeTotal + + if input.Body.PaymentMethodID == nil || *input.Body.PaymentMethodID == "" { + return nil, errors.New("payment method required for booking") + } + + + params := &stripe.PaymentIntentCreateParams{ + Amount: stripe.Int64(input.Body.Amount), + Currency: stripe.String(input.Body.Currency), + OnBehalfOf: stripe.String(input.Body.OrgStripeID), + Customer: stripe.String(input.Body.GuardianStripeID), + ApplicationFeeAmount: stripe.Int64(applicationFeeTotal), + TransferData: &stripe.PaymentIntentCreateTransferDataParams{ + Destination: stripe.String(input.Body.OrgStripeID), + Amount: stripe.Int64(organizationProfit), + }, + OffSession: stripe.Bool(true), + Metadata: map[string]string{ + "event_date": input.Body.EventDate.Format(time.RFC3339), + }, + } + + if input.Body.PaymentMethodID != nil { + // reusing saved card + params.PaymentMethod = stripe.String(*input.Body.PaymentMethodID) + params.OffSession = stripe.Bool(true) + } else { + // New card - save for future use + params.SetupFutureUsage = stripe.String("off_session") + } + + intent, err := sc.client.V1PaymentIntents.Create(ctx, params) + if err != nil { + return nil, err + } + + + output := &models.CreatePaymentIntentOutput{} + output.Body.ClientSecret = intent.ClientSecret + output.Body.PaymentIntentID = intent.ID + output.Body.Status = string(intent.Status) + output.Body.TotalAmount = int(intent.Amount) + output.Body.ProviderAmount = int(intent.TransferData.Amount) + output.Body.PlatformFeeAmount = int(intent.ApplicationFeeAmount) + output.Body.Currency = input.Body.Currency + + return output, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/createPaymentIntent_test.go b/backend/internal/stripeClient/createPaymentIntent_test.go new file mode 100644 index 00000000..4b572fb2 --- /dev/null +++ b/backend/internal/stripeClient/createPaymentIntent_test.go @@ -0,0 +1,193 @@ +package stripeClient + +import ( + "context" + "testing" + "time" + + "skillspark/internal/models" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripeClient_CreatePaymentIntent(t *testing.T) { + t.Skip("Requires Express account with transfer capability - times out without proper setup") + + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + t.Run("Successfully creates payment intent with test payment method", func(t *testing.T) { + customer, err := client.CreateCustomer(ctx, "paymenttest@example.com", "Payment Test User") + require.NoError(t, err) + + org, err := client.CreateOrganizationAccount( + ctx, + "Payment Test Org", + "paymentorg"+time.Now().Format("20060102150405")+"@example.com", + "US", + ) + require.NoError(t, err) + t.Logf("Created test account: %s", org.Body.Account.ID) + + paymentMethodID := "pm_card_visa" + + input := &models.CreatePaymentIntentInput{} + input.Body.Amount = 10000 + input.Body.Currency = "usd" + input.Body.GuardianStripeID = customer.ID + input.Body.OrgStripeID = org.Body.Account.ID + input.Body.PaymentMethodID = &paymentMethodID + input.Body.EventDate = time.Now().Add(24 * time.Hour) + input.Body.RegistrationID = uuid.New() + input.Body.GuardianID = uuid.New() + input.Body.ProviderOrgID = uuid.New() + + output, err := client.CreatePaymentIntent(ctx, input) + + if err != nil { + if assert.Contains(t, err.Error(), "capabilities") { + t.Skip("Account capabilities not active yet - this is expected for new test accounts") + } + require.NoError(t, err) + } + + require.NotNil(t, output) + assert.NotEmpty(t, output.Body.PaymentIntentID) + assert.NotEmpty(t, output.Body.ClientSecret) + assert.NotEmpty(t, output.Body.Status) + assert.Contains(t, output.Body.PaymentIntentID, "pi_") + + t.Logf("✓ Created payment intent: %s", output.Body.PaymentIntentID) + t.Logf("✓ Status: %s", output.Body.Status) + }) + + t.Run("Fails when payment method is nil", func(t *testing.T) { + customer, err := client.CreateCustomer(ctx, "nopm@example.com", "No PM User") + require.NoError(t, err) + + org, err := client.CreateOrganizationAccount( + ctx, + "No PM Org", + "nopm"+time.Now().Format("20060102150405")+"@example.com", + "US", + ) + require.NoError(t, err) + + input := &models.CreatePaymentIntentInput{} + input.Body.Amount = 5000 + input.Body.Currency = "usd" + input.Body.GuardianStripeID = customer.ID + input.Body.OrgStripeID = org.Body.Account.ID + input.Body.PaymentMethodID = nil + input.Body.EventDate = time.Now().Add(24 * time.Hour) + input.Body.RegistrationID = uuid.New() + input.Body.GuardianID = uuid.New() + input.Body.ProviderOrgID = uuid.New() + + output, err := client.CreatePaymentIntent(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + assert.Contains(t, err.Error(), "payment method required") + }) + + t.Run("Fails when payment method is empty string", func(t *testing.T) { + customer, err := client.CreateCustomer(ctx, "emptypm@example.com", "Empty PM User") + require.NoError(t, err) + + org, err := client.CreateOrganizationAccount( + ctx, + "Empty PM Org", + "emptypm"+time.Now().Format("20060102150405")+"@example.com", + "US", + ) + require.NoError(t, err) + + emptyPM := "" + input := &models.CreatePaymentIntentInput{} + input.Body.Amount = 5000 + input.Body.Currency = "usd" + input.Body.GuardianStripeID = customer.ID + input.Body.OrgStripeID = org.Body.Account.ID + input.Body.PaymentMethodID = &emptyPM + input.Body.EventDate = time.Now().Add(24 * time.Hour) + input.Body.RegistrationID = uuid.New() + input.Body.GuardianID = uuid.New() + input.Body.ProviderOrgID = uuid.New() + + output, err := client.CreatePaymentIntent(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + assert.Contains(t, err.Error(), "payment method required") + }) + + t.Run("Fails with invalid customer ID", func(t *testing.T) { + org, err := client.CreateOrganizationAccount( + ctx, + "Invalid Cust Org", + "invalidcust"+time.Now().Format("20060102150405")+"@example.com", + "US", + ) + require.NoError(t, err) + + paymentMethodID := "pm_card_visa" + input := &models.CreatePaymentIntentInput{} + input.Body.Amount = 5000 + input.Body.Currency = "usd" + input.Body.GuardianStripeID = "cus_nonexistent123" + input.Body.OrgStripeID = org.Body.Account.ID + input.Body.PaymentMethodID = &paymentMethodID + input.Body.EventDate = time.Now().Add(24 * time.Hour) + input.Body.RegistrationID = uuid.New() + input.Body.GuardianID = uuid.New() + input.Body.ProviderOrgID = uuid.New() + + output, err := client.CreatePaymentIntent(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + assert.Contains(t, err.Error(), "No such customer") + }) + + t.Run("Fails with invalid organization account ID", func(t *testing.T) { + customer, err := client.CreateCustomer(ctx, "invalidorg@example.com", "Invalid Org User") + require.NoError(t, err) + + paymentMethodID := "pm_card_visa" + input := &models.CreatePaymentIntentInput{} + input.Body.Amount = 5000 + input.Body.Currency = "usd" + input.Body.GuardianStripeID = customer.ID + input.Body.OrgStripeID = "acct_nonexistent123" + input.Body.PaymentMethodID = &paymentMethodID + input.Body.EventDate = time.Now().Add(24 * time.Hour) + input.Body.RegistrationID = uuid.New() + input.Body.GuardianID = uuid.New() + input.Body.ProviderOrgID = uuid.New() + + output, err := client.CreatePaymentIntent(ctx, input) + + assert.Error(t, err) + assert.Nil(t, output) + }) + + t.Run("Validates application fee calculation", func(t *testing.T) { + amount := int64(10000) + expectedFee := int64(1000) + expectedOrgProfit := int64(9000) + + calculatedFee := (amount * 10) / 100 + calculatedProfit := amount - calculatedFee + + assert.Equal(t, expectedFee, calculatedFee) + assert.Equal(t, expectedOrgProfit, calculatedProfit) + }) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/detachPaymentMethod.go b/backend/internal/stripeClient/detachPaymentMethod.go new file mode 100644 index 00000000..1045ce33 --- /dev/null +++ b/backend/internal/stripeClient/detachPaymentMethod.go @@ -0,0 +1,18 @@ +package stripeClient + +import ( + "context" + + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) DetachPaymentMethod( + ctx context.Context, + paymentMethodID string, +) error { + params := &stripe.PaymentMethodDetachParams{} + params.Context = ctx + + _, err := sc.client.V1PaymentMethods.Detach(ctx, paymentMethodID, params) + return err +} \ No newline at end of file diff --git a/backend/internal/stripeClient/detachPaymentMethod_test.go b/backend/internal/stripeClient/detachPaymentMethod_test.go new file mode 100644 index 00000000..e433adf6 --- /dev/null +++ b/backend/internal/stripeClient/detachPaymentMethod_test.go @@ -0,0 +1,43 @@ +package stripeClient + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStripeClient_DetachPaymentMethod(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + ctx := context.Background() + + t.Run("Successfully detaches payment method from customer", func(t *testing.T) { + t.Skip("Skipping - requires real card confirmation flow") + }) + + t.Run("Fails with invalid payment method ID", func(t *testing.T) { + err := client.DetachPaymentMethod(ctx, "pm_invalid123") + + assert.Error(t, err) + }) + + t.Run("Fails with empty payment method ID", func(t *testing.T) { + err := client.DetachPaymentMethod(ctx, "") + + assert.Error(t, err) + }) + + t.Run("Fails when payment method not attached", func(t *testing.T) { + paymentMethodID := "pm_card_visa" + + err := client.DetachPaymentMethod(ctx, paymentMethodID) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "not attached") + }) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/getAccountById.go b/backend/internal/stripeClient/getAccountById.go new file mode 100644 index 00000000..d99687d0 --- /dev/null +++ b/backend/internal/stripeClient/getAccountById.go @@ -0,0 +1,28 @@ +package stripeClient + +import ( + "context" + "github.com/stripe/stripe-go/v84" +) + +func (sc *StripeClient) GetAccount( + ctx context.Context, + accountID string, +) (*stripe.V2CoreAccount, error) { + params := &stripe.V2CoreAccountRetrieveParams{ + Include: []*string{ + stripe.String("defaults"), + stripe.String("identity"), + stripe.String("configuration.merchant"), + stripe.String("configuration.recipient"), + }, + } + + acct, err := sc.client.V2CoreAccounts.Retrieve(ctx, accountID, params) + if err != nil { + return nil, err + } + + return acct, nil + +} \ No newline at end of file diff --git a/backend/internal/stripeClient/getAccountById_test.go b/backend/internal/stripeClient/getAccountById_test.go new file mode 100644 index 00000000..a12265eb --- /dev/null +++ b/backend/internal/stripeClient/getAccountById_test.go @@ -0,0 +1,169 @@ +package stripeClient + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stripe/stripe-go/v84" +) + +func TestGetAccount(t *testing.T) { + if testing.Short() { + t.Skip("Skipping Stripe integration test in short mode") + } + + apiKey := getTestStripeAPIKey(t) + client := NewStripeClient(apiKey) + + // Add timeout to prevent hanging + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + t.Run("Successfully retrieves existing account", func(t *testing.T) { + // First create an account + createdAccount, err := client.CreateOrganizationAccount( + ctx, + "Get Test Org", + "gettest@example.com", + "TH", + ) + require.NoError(t, err) + require.NotNil(t, createdAccount) + + // Now retrieve it + account, err := client.GetAccount(ctx, createdAccount.Body.Account.ID) + + require.NoError(t, err) + require.NotNil(t, account) + assert.Equal(t, createdAccount.Body.Account.ID, account.ID) + assert.Equal(t, "gettest@example.com", account.ContactEmail) + assert.Equal(t, "Get Test Org", account.DisplayName) + + // Verify included data is present + assert.NotNil(t, account.Configuration) + assert.NotNil(t, account.Configuration.Merchant) + assert.NotNil(t, account.Configuration.Recipient) + assert.NotNil(t, account.Identity) + assert.NotNil(t, account.Defaults) + + t.Logf("Retrieved account: %s", account.ID) + }) + + t.Run("Successfully retrieves account configuration details", func(t *testing.T) { + // Create account + createdAccount, err := client.CreateOrganizationAccount( + ctx, + "Config Test Org", + "configtest@example.com", + "US", + ) + require.NoError(t, err) + + // Retrieve it + account, err := client.GetAccount(ctx, createdAccount.Body.Account.ID) + + require.NoError(t, err) + + // Verify merchant capabilities + assert.NotNil(t, account.Configuration.Merchant.Capabilities.CardPayments) + assert.NotEmpty(t, account.Configuration.Merchant.Capabilities.CardPayments.Status) + + // Verify recipient capabilities + assert.NotNil(t, account.Configuration.Recipient.Capabilities.StripeBalance) + assert.NotNil(t, account.Configuration.Recipient.Capabilities.StripeBalance.StripeTransfers) + assert.NotEmpty(t, account.Configuration.Recipient.Capabilities.StripeBalance.StripeTransfers.Status) + + t.Logf("Card Payments Status: %s", account.Configuration.Merchant.Capabilities.CardPayments.Status) + t.Logf("Stripe Transfers Status: %s", account.Configuration.Recipient.Capabilities.StripeBalance.StripeTransfers.Status) + }) + + t.Run("Successfully retrieves identity information", func(t *testing.T) { + // Create account + createdAccount, err := client.CreateOrganizationAccount( + ctx, + "Identity Test Org", + "identity@example.com", + "TH", + ) + require.NoError(t, err) + + // Retrieve it + account, err := client.GetAccount(ctx, createdAccount.Body.Account.ID) + + require.NoError(t, err) + assert.NotNil(t, account.Identity) + assert.Equal(t, "TH", account.Identity.Country) + }) + + t.Run("Successfully retrieves defaults", func(t *testing.T) { + // Create account + createdAccount, err := client.CreateOrganizationAccount( + ctx, + "Defaults Test Org", + "defaults@example.com", + "US", + ) + require.NoError(t, err) + + // Retrieve it + account, err := client.GetAccount(ctx, createdAccount.Body.Account.ID) + + require.NoError(t, err) + assert.NotNil(t, account.Defaults) + assert.NotEmpty(t, account.Defaults.Currency) + + // Verify responsibilities using Stripe constants + assert.NotNil(t, account.Defaults.Responsibilities) + assert.Equal(t, + stripe.V2CoreAccountDefaultsResponsibilitiesLossesCollectorApplication, + account.Defaults.Responsibilities.LossesCollector) + assert.Equal(t, + stripe.V2CoreAccountDefaultsResponsibilitiesFeesCollectorApplication, + account.Defaults.Responsibilities.FeesCollector) + + t.Logf("Default Currency: %s", account.Defaults.Currency) + t.Logf("Losses Collector: %s", account.Defaults.Responsibilities.LossesCollector) + t.Logf("Fees Collector: %s", account.Defaults.Responsibilities.FeesCollector) + }) + + t.Run("Fails with invalid account ID", func(t *testing.T) { + account, err := client.GetAccount(ctx, "acct_invalid123") + + assert.Error(t, err) + assert.Nil(t, account) + }) + + t.Run("Fails with empty account ID", func(t *testing.T) { + account, err := client.GetAccount(ctx, "") + + assert.Error(t, err) + assert.Nil(t, account) + }) + + t.Run("Can retrieve same account multiple times", func(t *testing.T) { + // Create account once + createdAccount, err := client.CreateOrganizationAccount( + ctx, + "Multiple Retrieve Org", + "multiretrieve@example.com", + "TH", + ) + require.NoError(t, err) + + accountID := createdAccount.Body.Account.ID + + // Retrieve multiple times + account1, err := client.GetAccount(ctx, accountID) + require.NoError(t, err) + + account2, err := client.GetAccount(ctx, accountID) + require.NoError(t, err) + + // Should return same data + assert.Equal(t, account1.ID, account2.ID) + assert.Equal(t, account1.ContactEmail, account2.ContactEmail) + }) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/getAccountStatus.go b/backend/internal/stripeClient/getAccountStatus.go new file mode 100644 index 00000000..20fdbeb2 --- /dev/null +++ b/backend/internal/stripeClient/getAccountStatus.go @@ -0,0 +1,59 @@ +package stripeClient + +import ( + "context" + + "github.com/stripe/stripe-go/v84" +) + +type AccountStatus struct { + AccountID string + ChargesEnabled bool + PayoutsEnabled bool + RequirementsErrors []error + CurrentlyDue []string + EventuallyDue []string +} + +func (sc *StripeClient) GetAccountStatus( + ctx context.Context, + accountID string, +) (*AccountStatus, error) { + params := &stripe.V2CoreAccountRetrieveParams{ + Include: []*string{ + stripe.String("defaults"), + stripe.String("identity"), + stripe.String("configuration.merchant"), + }, + } + + // deprecated replace + params.AddExpand("requirements") + + acct, err := sc.client.V2CoreAccounts.Retrieve(ctx, accountID, params) + if err != nil { + return nil, err + } + + + status := &AccountStatus{ + AccountID: acct.ID, + } + + if acct.Configuration != nil && acct.Configuration.Merchant != nil { + if cardPayments := acct.Configuration.Merchant.Capabilities.CardPayments; cardPayments != nil { + status.ChargesEnabled = cardPayments.Status == "active" + } + } + + if acct.Configuration != nil && acct.Configuration.Recipient != nil { + if stripeBalance := acct.Configuration.Recipient.Capabilities.StripeBalance; stripeBalance != nil { + if stripeTransfers := stripeBalance.StripeTransfers; stripeTransfers != nil { + status.PayoutsEnabled = stripeTransfers.Status == "active" + } + } + } + + // TODO: add requirement info to status + return status, nil +} \ No newline at end of file diff --git a/backend/internal/stripeClient/mocks/mockStripeClient.go b/backend/internal/stripeClient/mocks/mockStripeClient.go new file mode 100644 index 00000000..9f41a688 --- /dev/null +++ b/backend/internal/stripeClient/mocks/mockStripeClient.go @@ -0,0 +1,116 @@ +package stripemocks + +import ( + "context" + "skillspark/internal/models" + "github.com/stretchr/testify/mock" + "github.com/stripe/stripe-go/v84" +) + +type MockStripeClient struct { + mock.Mock +} + +func (m *MockStripeClient) CreateOrganizationAccount( + ctx context.Context, + name string, + email string, + country string, +) (*models.CreateOrgStripeAccountOutput, error) { + args := m.Called(ctx, name, email, country) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CreateOrgStripeAccountOutput), args.Error(1) +} + +func (m *MockStripeClient) CreateAccountOnboardingLink( + ctx context.Context, + input *models.CreateStripeOnboardingLinkInput, +) (*models.CreateStripeOnboardingLinkOutput, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CreateStripeOnboardingLinkOutput), args.Error(1) +} + +func (m *MockStripeClient) CreateCustomer( + ctx context.Context, + email string, + name string, +) (*stripe.Customer, error) { + args := m.Called(ctx, email, name) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*stripe.Customer), args.Error(1) +} + +func (m *MockStripeClient) CreateSetupIntent( + ctx context.Context, + stripeCustomerID string, +) (string, error) { + args := m.Called(ctx, stripeCustomerID) + return args.String(0), args.Error(1) +} + +func (m *MockStripeClient) CreatePaymentIntent( + ctx context.Context, + input *models.CreatePaymentIntentInput, +) (*models.CreatePaymentIntentOutput, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CreatePaymentIntentOutput), args.Error(1) +} + +func (m *MockStripeClient) CapturePaymentIntent( + ctx context.Context, + input *models.CapturePaymentIntentInput, +) (*models.CapturePaymentIntentOutput, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CapturePaymentIntentOutput), args.Error(1) +} + +func (m *MockStripeClient) CancelPaymentIntent( + ctx context.Context, + input *models.CancelPaymentIntentInput, +) (*models.CancelPaymentIntentOutput, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*models.CancelPaymentIntentOutput), args.Error(1) +} + +func (m *MockStripeClient) GetAccount( + ctx context.Context, + accountID string, +) (*stripe.V2CoreAccount, error) { + args := m.Called(ctx, accountID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*stripe.V2CoreAccount), args.Error(1) +} + +func (m *MockStripeClient) DetachPaymentMethod( + ctx context.Context, + paymentMethodID string, +) error { + args := m.Called(ctx, paymentMethodID) + return args.Error(0) +} + +func (m *MockStripeClient) CreateLoginLink( + ctx context.Context, + accountID string, +) (string, error) { + args := m.Called(ctx, accountID) + return args.String(0), args.Error(1) +} \ No newline at end of file diff --git a/backend/internal/stripeClient/stripeClient.go b/backend/internal/stripeClient/stripeClient.go new file mode 100644 index 00000000..34a07df4 --- /dev/null +++ b/backend/internal/stripeClient/stripeClient.go @@ -0,0 +1,15 @@ +package stripeClient + +import ( + "github.com/stripe/stripe-go/v84" +) + +type StripeClient struct { + client *stripe.Client +} + +func NewStripeClient(apiKey string) *StripeClient { + return &StripeClient{ + client: stripe.NewClient(apiKey), + } +} \ No newline at end of file diff --git a/backend/internal/stripeClient/stripeClientInterface.go b/backend/internal/stripeClient/stripeClientInterface.go new file mode 100644 index 00000000..77c0419e --- /dev/null +++ b/backend/internal/stripeClient/stripeClientInterface.go @@ -0,0 +1,21 @@ +package stripeClient + +import ( + "context" + "skillspark/internal/models" + + "github.com/stripe/stripe-go/v84" +) + +type StripeClientInterface interface { + CreateOrganizationAccount(ctx context.Context, name string, email string, country string) (*models.CreateOrgStripeAccountOutput, error) + CreateAccountOnboardingLink(ctx context.Context, input *models.CreateStripeOnboardingLinkInput) (*models.CreateStripeOnboardingLinkOutput, error) + CreateCustomer(ctx context.Context, email string, name string) (*stripe.Customer, error) + CreateSetupIntent(ctx context.Context, stripeCustomerID string) (string, error) + CreatePaymentIntent(ctx context.Context, input *models.CreatePaymentIntentInput) (*models.CreatePaymentIntentOutput, error) + GetAccount(ctx context.Context, accountID string) (*stripe.V2CoreAccount, error) + DetachPaymentMethod(ctx context.Context, paymentMethodID string) error + CreateLoginLink(ctx context.Context, accountID string) (string, error) + CancelPaymentIntent(ctx context.Context, input *models.CancelPaymentIntentInput) (*models.CancelPaymentIntentOutput, error) + CapturePaymentIntent(ctx context.Context, input *models.CapturePaymentIntentInput) (*models.CapturePaymentIntentOutput, error) +} \ No newline at end of file diff --git a/backend/internal/supabase/migrations/20260209034835_add_stripe_integration_to_db.sql b/backend/internal/supabase/migrations/20260209034835_add_stripe_integration_to_db.sql new file mode 100644 index 00000000..8d7db57f --- /dev/null +++ b/backend/internal/supabase/migrations/20260209034835_add_stripe_integration_to_db.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS guardian_payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + guardian_id UUID NOT NULL REFERENCES guardian(id) ON DELETE CASCADE, + stripe_payment_method_id VARCHAR(255) NOT NULL UNIQUE, + + card_brand VARCHAR(50), + card_last4 VARCHAR(4), + card_exp_month INTEGER, + card_exp_year INTEGER, + + is_default BOOLEAN DEFAULT false NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + + CONSTRAINT unique_guardian_payment_method UNIQUE(guardian_id, stripe_payment_method_id) +); + +CREATE INDEX IF NOT EXISTS idx_guardian_payment_methods ON guardian_payment_methods(guardian_id); +CREATE INDEX IF NOT EXISTS idx_default_payment_method ON guardian_payment_methods(guardian_id, is_default) WHERE is_default = true; + +CREATE TYPE payment_intent_status AS ENUM ( + 'requires_payment_method', + 'requires_confirmation', + 'requires_action', + 'processing', + 'requires_capture', + 'canceled', + 'succeeded' +); + +ALTER TABLE registration +ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255), +ADD COLUMN IF NOT EXISTS org_stripe_account_id VARCHAR(255), +ADD COLUMN IF NOT EXISTS currency VARCHAR(3) DEFAULT 'thb', +ADD COLUMN IF NOT EXISTS payment_intent_status payment_intent_status, +ADD COLUMN IF NOT EXISTS cancelled_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS stripe_payment_intent_id VARCHAR(255) UNIQUE, +ADD COLUMN IF NOT EXISTS total_amount INTEGER, +ADD COLUMN IF NOT EXISTS provider_amount INTEGER, +ADD COLUMN IF NOT EXISTS platform_fee_amount INTEGER, +ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ, +ADD COLUMN IF NOT EXISTS stripe_payment_method_id VARCHAR(255); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_registration_payment_intent ON registration(stripe_payment_intent_id); +CREATE INDEX IF NOT EXISTS idx_registration_customer ON registration(stripe_customer_id); +CREATE INDEX IF NOT EXISTS idx_registration_provider_account ON registration(org_stripe_account_id); +CREATE INDEX IF NOT EXISTS idx_registration_payment_status ON registration(payment_intent_status); +CREATE INDEX IF NOT EXISTS idx_registration_guardian ON registration(guardian_id); +CREATE INDEX IF NOT EXISTS idx_registration_event ON registration(event_occurrence_id); + +ALTER TABLE organization +ADD COLUMN IF NOT EXISTS stripe_account_id VARCHAR(255) UNIQUE, +ADD COLUMN IF NOT EXISTS stripe_account_activated BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE guardian +ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255) UNIQUE; + + +ALTER TABLE event_occurrence +ADD COLUMN IF NOT EXISTS price INTEGER NOT NULL DEFAULT 0; -- Price in bhat + +CREATE INDEX IF NOT EXISTS idx_event_occurrence_price ON event_occurrence(price); + diff --git a/backend/internal/supabase/seed/10_registration.sql b/backend/internal/supabase/seed/10_registration.sql index a5d04ee5..35492c3e 100644 --- a/backend/internal/supabase/seed/10_registration.sql +++ b/backend/internal/supabase/seed/10_registration.sql @@ -1,54 +1,59 @@ -- ============================================ -- 10. REGISTRATIONS -- ============================================ -INSERT INTO registration (id, child_id, guardian_id, event_occurrence_id, status) VALUES +INSERT INTO registration ( + id, child_id, guardian_id, event_occurrence_id, status, + stripe_payment_intent_id, stripe_customer_id, org_stripe_account_id, + stripe_payment_method_id, total_amount, provider_amount, + platform_fee_amount, currency, payment_intent_status +) VALUES -- Emily Johnson (science, technology, math interests) -('80000000-0000-0000-0000-000000000001', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000001', 'registered'), -('80000000-0000-0000-0000-000000000002', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000003', 'registered'), -('80000000-0000-0000-0000-000000000003', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000012', 'registered'), +('80000000-0000-0000-0000-000000000001', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000001', 'registered', 'pi_seed_001', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000002', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000003', 'registered', 'pi_seed_002', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000003', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000012', 'registered', 'pi_seed_003', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Alex Johnson (sports, music interests) -('80000000-0000-0000-0000-000000000004', '30000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000006', 'registered'), -('80000000-0000-0000-0000-000000000005', '30000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-00000000000e', 'registered'), +('80000000-0000-0000-0000-000000000004', '30000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000006', 'registered', 'pi_seed_004', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000005', '30000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-00000000000e', 'registered', 'pi_seed_005', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Sophie Chen (art, language, music interests) -('80000000-0000-0000-0000-000000000006', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-00000000000b', 'registered'), -('80000000-0000-0000-0000-000000000007', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-000000000010', 'registered'), -('80000000-0000-0000-0000-000000000008', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-000000000011', 'registered'), +('80000000-0000-0000-0000-000000000006', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-00000000000b', 'registered', 'pi_seed_006', 'cus_22222222', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000007', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-000000000010', 'registered', 'pi_seed_007', 'cus_22222222', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000008', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-000000000011', 'registered', 'pi_seed_008', 'cus_22222222', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Aiden Patel (science, sports, technology interests) -('80000000-0000-0000-0000-000000000009', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000001', 'registered'), -('80000000-0000-0000-0000-00000000000a', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000008', 'registered'), -('80000000-0000-0000-0000-00000000000b', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000014', 'registered'), +('80000000-0000-0000-0000-000000000009', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000001', 'registered', 'pi_seed_009', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000000a', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000008', 'registered', 'pi_seed_00a', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000000b', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000014', 'registered', 'pi_seed_00b', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Maya Patel (art, music interests) -('80000000-0000-0000-0000-00000000000c', '30000000-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-00000000000b', 'registered'), -('80000000-0000-0000-0000-00000000000d', '30000000-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000011', 'registered'), +('80000000-0000-0000-0000-00000000000c', '30000000-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-00000000000b', 'registered', 'pi_seed_00c', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000000d', '30000000-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000011', 'registered', 'pi_seed_00d', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Lucas Rodriguez (sports, technology interests) -('80000000-0000-0000-0000-00000000000e', '30000000-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', '70000000-0000-0000-0000-000000000006', 'registered'), -('80000000-0000-0000-0000-00000000000f', '30000000-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', '70000000-0000-0000-0000-000000000013', 'registered'), +('80000000-0000-0000-0000-00000000000e', '30000000-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', '70000000-0000-0000-0000-000000000006', 'registered', 'pi_seed_00e', 'cus_44444444', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000000f', '30000000-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', '70000000-0000-0000-0000-000000000013', 'registered', 'pi_seed_00f', 'cus_44444444', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Isabella Thompson (language, art interests) -('80000000-0000-0000-0000-000000000010', '30000000-0000-0000-0000-000000000007', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-00000000000b', 'registered'), -('80000000-0000-0000-0000-000000000011', '30000000-0000-0000-0000-000000000007', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-00000000000c', 'registered'), +('80000000-0000-0000-0000-000000000010', '30000000-0000-0000-0000-000000000007', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-00000000000b', 'registered', 'pi_seed_010', 'cus_55555555', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000011', '30000000-0000-0000-0000-000000000007', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-00000000000c', 'registered', 'pi_seed_011', 'cus_55555555', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Ethan Thompson (math, science interests) -('80000000-0000-0000-0000-000000000012', '30000000-0000-0000-0000-000000000008', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-000000000001', 'registered'), -('80000000-0000-0000-0000-000000000013', '30000000-0000-0000-0000-000000000008', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-000000000003', 'registered'), +('80000000-0000-0000-0000-000000000012', '30000000-0000-0000-0000-000000000008', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-000000000001', 'registered', 'pi_seed_012', 'cus_55555555', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000013', '30000000-0000-0000-0000-000000000008', '55555555-5555-5555-5555-555555555555', '70000000-0000-0000-0000-000000000003', 'registered', 'pi_seed_013', 'cus_55555555', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Hana Tanaka (music, language, art interests) -('80000000-0000-0000-0000-000000000014', '30000000-0000-0000-0000-000000000009', '66666666-6666-6666-6666-666666666666', '70000000-0000-0000-0000-00000000000e', 'registered'), -('80000000-0000-0000-0000-000000000015', '30000000-0000-0000-0000-000000000009', '66666666-6666-6666-6666-666666666666', '70000000-0000-0000-0000-00000000000b', 'registered'), -('80000000-0000-0000-0000-000000000016', '30000000-0000-0000-0000-000000000009', '66666666-6666-6666-6666-666666666666', '70000000-0000-0000-0000-000000000011', 'registered'), +('80000000-0000-0000-0000-000000000014', '30000000-0000-0000-0000-000000000009', '66666666-6666-6666-6666-666666666666', '70000000-0000-0000-0000-00000000000e', 'registered', 'pi_seed_014', 'cus_66666666', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000015', '30000000-0000-0000-0000-000000000009', '66666666-6666-6666-6666-666666666666', '70000000-0000-0000-0000-00000000000b', 'registered', 'pi_seed_015', 'cus_66666666', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000016', '30000000-0000-0000-0000-000000000009', '66666666-6666-6666-6666-666666666666', '70000000-0000-0000-0000-000000000011', 'registered', 'pi_seed_016', 'cus_66666666', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Noah Martinez (sports, science interests) -('80000000-0000-0000-0000-000000000017', '30000000-0000-0000-0000-00000000000a', '77777777-7777-7777-7777-777777777777', '70000000-0000-0000-0000-000000000007', 'registered'), -('80000000-0000-0000-0000-000000000018', '30000000-0000-0000-0000-00000000000a', '77777777-7777-7777-7777-777777777777', '70000000-0000-0000-0000-000000000005', 'registered'), +('80000000-0000-0000-0000-000000000017', '30000000-0000-0000-0000-00000000000a', '77777777-7777-7777-7777-777777777777', '70000000-0000-0000-0000-000000000007', 'registered', 'pi_seed_017', 'cus_77777777', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000018', '30000000-0000-0000-0000-00000000000a', '77777777-7777-7777-7777-777777777777', '70000000-0000-0000-0000-000000000005', 'registered', 'pi_seed_018', 'cus_77777777', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Liam Wilson (technology, math, science interests) -('80000000-0000-0000-0000-000000000019', '30000000-0000-0000-0000-00000000000b', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000002', 'registered'), -('80000000-0000-0000-0000-00000000001a', '30000000-0000-0000-0000-00000000000b', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000014', 'registered'), -('80000000-0000-0000-0000-00000000001b', '30000000-0000-0000-0000-00000000000b', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000015', 'registered'), +('80000000-0000-0000-0000-000000000019', '30000000-0000-0000-0000-00000000000b', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000002', 'registered', 'pi_seed_019', 'cus_88888888', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000001a', '30000000-0000-0000-0000-00000000000b', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000014', 'registered', 'pi_seed_01a', 'cus_88888888', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000001b', '30000000-0000-0000-0000-00000000000b', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000015', 'registered', 'pi_seed_01b', 'cus_88888888', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Ava Wilson (music, art interests) -('80000000-0000-0000-0000-00000000001c', '30000000-0000-0000-0000-00000000000c', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-00000000000f', 'registered'), -('80000000-0000-0000-0000-00000000001d', '30000000-0000-0000-0000-00000000000c', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000011', 'registered'), +('80000000-0000-0000-0000-00000000001c', '30000000-0000-0000-0000-00000000000c', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-00000000000f', 'registered', 'pi_seed_01c', 'cus_88888888', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000001d', '30000000-0000-0000-0000-00000000000c', '88888888-8888-8888-8888-888888888888', '70000000-0000-0000-0000-000000000011', 'registered', 'pi_seed_01d', 'cus_88888888', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Additional registrations showing variety -('80000000-0000-0000-0000-00000000001e', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000004', 'registered'), -('80000000-0000-0000-0000-00000000001f', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000009', 'registered'), -('80000000-0000-0000-0000-000000000020', '30000000-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', '70000000-0000-0000-0000-00000000000a', 'registered'), -('80000000-0000-0000-0000-000000000021', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-00000000000d', 'registered'), +('80000000-0000-0000-0000-00000000001e', '30000000-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000004', 'registered', 'pi_seed_01e', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-00000000001f', '30000000-0000-0000-0000-000000000004', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-000000000009', 'registered', 'pi_seed_01f', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000020', '30000000-0000-0000-0000-000000000006', '44444444-4444-4444-4444-444444444444', '70000000-0000-0000-0000-00000000000a', 'registered', 'pi_seed_020', 'cus_44444444', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), +('80000000-0000-0000-0000-000000000021', '30000000-0000-0000-0000-000000000003', '22222222-2222-2222-2222-222222222222', '70000000-0000-0000-0000-00000000000d', 'registered', 'pi_seed_021', 'cus_22222222', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'requires_capture'), -- Some cancelled registrations -('80000000-0000-0000-0000-000000000022', '30000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000009', 'cancelled'), -('80000000-0000-0000-0000-000000000023', '30000000-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-00000000000c', 'cancelled'), -('80000000-0000-0000-0000-000000000024', '30000000-0000-0000-0000-00000000000a', '77777777-7777-7777-7777-777777777777', '70000000-0000-0000-0000-000000000003', 'cancelled'); +('80000000-0000-0000-0000-000000000022', '30000000-0000-0000-0000-000000000002', '11111111-1111-1111-1111-111111111111', '70000000-0000-0000-0000-000000000009', 'cancelled', 'pi_seed_022', 'cus_11111111', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'succeeded'), +('80000000-0000-0000-0000-000000000023', '30000000-0000-0000-0000-000000000005', '33333333-3333-3333-3333-333333333333', '70000000-0000-0000-0000-00000000000c', 'cancelled', 'pi_seed_023', 'cus_33333333', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'succeeded'), +('80000000-0000-0000-0000-000000000024', '30000000-0000-0000-0000-00000000000a', '77777777-7777-7777-7777-777777777777', '70000000-0000-0000-0000-000000000003', 'cancelled', 'pi_seed_024', 'cus_77777777', 'acct_seed_123', 'pm_seed_123', 10000, 8500, 1500, 'usd', 'succeeded'); \ No newline at end of file