diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 318a1625..037add05 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -109,6 +109,8 @@ jobs: ORDER_PAYMENT_WALLET_ID=${{ secrets.ORDER_PAYMENT_WALLET_ID }} ORDER_WEB_BASE_URL=${{ secrets.ORDER_WEB_BASE_URL }} ORDER_PAYMENT_CANCEL_ENABLED=${{ secrets.ORDER_PAYMENT_CANCEL_ENABLED }} + ORDER_COD_PAYMENT_ENABLED=${{ secrets.ORDER_COD_PAYMENT_ENABLED }} + COD_VERIFICATION_EXPIRY_SECONDS=${{ secrets.COD_VERIFICATION_EXPIRY_SECONDS }} EXPO_PUSH_ACCESS_TOKEN=${{ secrets.EXPO_PUSH_ACCESS_TOKEN }} KDS_PUSH_SOUND=default KDS_DISPATCH_INTERVAL_SECONDS=15 diff --git a/.github/workflows/e2e-main.yml b/.github/workflows/e2e-main.yml index fd5dff5c..fb3ceef7 100644 --- a/.github/workflows/e2e-main.yml +++ b/.github/workflows/e2e-main.yml @@ -225,6 +225,7 @@ jobs: FONNTE_BASE_URL: http://127.0.0.1:${{ env.FONNTESTUB_PORT }} WHATSAPP_NUMBER_VALIDATION_ENABLED: 'true' ORDER_WEB_BASE_URL: http://localhost:3000 + ORDER_COD_PAYMENT_ENABLED: 'true' run: | nohup ./dist/apps/api > "$RUNNER_TEMP/api.log" 2>&1 & # Any HTTP response means the server is listening (GET on the diff --git a/README.md b/README.md index 52523b69..3a6dff9c 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,20 @@ order-app guest gets to pay cash at the till before the order is cancelled autom at all; `NEXT_PUBLIC_ORDER_CASHIER_LOCATION` (default `Lantai 1`) is the till location shown to the guest in both the checkout sheet and the cash instruction screen. +`apps/api/.env`'s `ORDER_COD_PAYMENT_ENABLED` (default `false`) is the server-side gate for the +Cash on Delivery trial — the authoritative one, since it is what stops an old tab, a curl, or a +cached bundle from creating a COD order (and the verification photo that comes with it) once the +trial is off (`docs/prd-order-cod-payment.md`, D12). `COD_VERIFICATION_EXPIRY_SECONDS` (default +`900`) is how long an unverified COD order waits for a barista before the sweeper cancels it and +frees the guest's cart. `apps/order-web/.env.local`'s `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED` is +the method's own client-side kill switch, off by default and only ever an affordance — the API flag +above is what actually matters. Migration `000044_add_payment_verification` must be applied +(`MIGRATIONS_DIR=data/mysql/migrations make migrate-up` from `apps/api`) before the API binary that +expects the `payment_verification_photos` table starts. In production, turning the trial on is +`ORDER_COD_PAYMENT_ENABLED=true` on the API and `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED=true` on +`order-web`, alongside `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED=false` — COD is a superset of cash +from the guest's point of view, so the two trial flags are meant to be flipped together. + `apps/api/.env`'s `FONNTE_TOKEN`, `FONNTE_BASE_URL` and `ORDER_WEB_BASE_URL` configure the WhatsApp order-ready notification sent when a barista marks an order-app order ready (`docs/prd-order-whatsapp-notifications.md`) — leaving `FONNTE_TOKEN` or `ORDER_WEB_BASE_URL` diff --git a/apps/api/.env.example b/apps/api/.env.example index 4ead996a..02cc31cb 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,13 @@ ORDER_PAYMENT_WALLET_ID= # the QRIS wallet's id; validated by checkout fro ORDER_WEB_BASE_URL= # apps/order-web's public origin, no trailing slash (docs/prd-order-whatsapp-notifications.md D11); empty disables the WhatsApp gateway ORDER_PAYMENT_CANCEL_ENABLED= # kill switch for guest payment cancellation (docs/prd-order-payment-cancellation.md D11); empty/false until phase 10 flips it on +# Cash on Delivery (COD) trial (docs/prd-order-cod-payment.md). ORDER_COD_PAYMENT_ENABLED gates +# checkout only (D12) — verify/approve/reject stay available so in-flight orders can drain after +# the trial is turned off. COD_VERIFICATION_EXPIRY_SECONDS is how long an unverified order waits +# for a barista before the sweeper expires it (D8, D19); an approved order never expires. +ORDER_COD_PAYMENT_ENABLED=false +COD_VERIFICATION_EXPIRY_SECONDS=900 + # Expo push notifications for the KDS app (docs/prd-kds-order-notifications.md D7). # The API is the only thing that ever calls Expo — no frontend holds this token. EXPO_PUSH_ACCESS_TOKEN= # Expo access token, sent as "Authorization: Bearer ..." to exp.host diff --git a/apps/api/data/mock/payment_verification_repository.go b/apps/api/data/mock/payment_verification_repository.go new file mode 100644 index 00000000..67ad8619 --- /dev/null +++ b/apps/api/data/mock/payment_verification_repository.go @@ -0,0 +1,100 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: payment_verification_repository.go +// +// Generated by this command: +// +// mockgen -source=payment_verification_repository.go -destination=../data/mock/payment_verification_repository.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + domain "apps/api/domain" + context "context" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockPaymentVerificationRepository is a mock of PaymentVerificationRepository interface. +type MockPaymentVerificationRepository struct { + ctrl *gomock.Controller + recorder *MockPaymentVerificationRepositoryMockRecorder + isgomock struct{} +} + +// MockPaymentVerificationRepositoryMockRecorder is the mock recorder for MockPaymentVerificationRepository. +type MockPaymentVerificationRepositoryMockRecorder struct { + mock *MockPaymentVerificationRepository +} + +// NewMockPaymentVerificationRepository creates a new mock instance. +func NewMockPaymentVerificationRepository(ctrl *gomock.Controller) *MockPaymentVerificationRepository { + mock := &MockPaymentVerificationRepository{ctrl: ctrl} + mock.recorder = &MockPaymentVerificationRepositoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPaymentVerificationRepository) EXPECT() *MockPaymentVerificationRepositoryMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockPaymentVerificationRepository) Create(ctx context.Context, photo domain.PaymentVerificationPhoto) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", ctx, photo) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// Create indicates an expected call of Create. +func (mr *MockPaymentVerificationRepositoryMockRecorder) Create(ctx, photo any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockPaymentVerificationRepository)(nil).Create), ctx, photo) +} + +// DeleteByPaymentId mocks base method. +func (m *MockPaymentVerificationRepository) DeleteByPaymentId(ctx context.Context, paymentId int64) *domain.Error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteByPaymentId", ctx, paymentId) + ret0, _ := ret[0].(*domain.Error) + return ret0 +} + +// DeleteByPaymentId indicates an expected call of DeleteByPaymentId. +func (mr *MockPaymentVerificationRepositoryMockRecorder) DeleteByPaymentId(ctx, paymentId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteByPaymentId", reflect.TypeOf((*MockPaymentVerificationRepository)(nil).DeleteByPaymentId), ctx, paymentId) +} + +// DeleteOrphaned mocks base method. +func (m *MockPaymentVerificationRepository) DeleteOrphaned(ctx context.Context, limit int) (int64, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteOrphaned", ctx, limit) + ret0, _ := ret[0].(int64) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// DeleteOrphaned indicates an expected call of DeleteOrphaned. +func (mr *MockPaymentVerificationRepositoryMockRecorder) DeleteOrphaned(ctx, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteOrphaned", reflect.TypeOf((*MockPaymentVerificationRepository)(nil).DeleteOrphaned), ctx, limit) +} + +// GetByPaymentId mocks base method. +func (m *MockPaymentVerificationRepository) GetByPaymentId(ctx context.Context, paymentId int64) (domain.PaymentVerificationPhoto, *domain.Error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByPaymentId", ctx, paymentId) + ret0, _ := ret[0].(domain.PaymentVerificationPhoto) + ret1, _ := ret[1].(*domain.Error) + return ret0, ret1 +} + +// GetByPaymentId indicates an expected call of GetByPaymentId. +func (mr *MockPaymentVerificationRepositoryMockRecorder) GetByPaymentId(ctx, paymentId any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByPaymentId", reflect.TypeOf((*MockPaymentVerificationRepository)(nil).GetByPaymentId), ctx, paymentId) +} diff --git a/apps/api/data/mysql/kds_notification_repo_test.go b/apps/api/data/mysql/kds_notification_repo_test.go index 663f38f8..999e6a9b 100644 --- a/apps/api/data/mysql/kds_notification_repo_test.go +++ b/apps/api/data/mysql/kds_notification_repo_test.go @@ -156,6 +156,51 @@ func TestEnqueueForTransaction_CashPendingBypassesTheStaleCheck(t *testing.T) { require.NoError(t, mock.ExpectationsWereMet()) } +// FR-9/D15: cod_verification asks a barista to look at a photo, not make a drink, so a board-game- +// only COD order still needs a decision. +func TestEnqueueForTransaction_CodVerificationBypassesTheStationRule(t *testing.T) { + repo, mock := newMockKdsNotificationRepository(t) + + transaction := domain.Transaction{ + Id: 1, + TransactionItems: []domain.TransactionItem{ + { + Amount: 1, + ProductName: "Board Game Ticket", + Variant: domain.Variant{Product: domain.Product{Category: domain.Category{Station: "NONE"}}}, + }, + }, + } + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `kds_notifications` \\(`transaction_id`,`kind`,`status`,`attempt_count`,`detail`,`created_at`,`sent_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?\\) ON DUPLICATE KEY UPDATE `id`=id"). + WithArgs(int64(1), "cod_verification", "pending", 0, nil, sqlmock.AnyArg(), nil). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.EnqueueForTransaction(context.Background(), transaction, domain.KdsNotificationKindCodVerification) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// FR-9/D15: a cod_verification row is written the instant the transaction is created, so the +// business-day staleness check can never fire for it either. +func TestEnqueueForTransaction_CodVerificationBypassesTheStaleCheck(t *testing.T) { + repo, mock := newMockKdsNotificationRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `kds_notifications` \\(`transaction_id`,`kind`,`status`,`attempt_count`,`detail`,`created_at`,`sent_at`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?,\\?,\\?\\) ON DUPLICATE KEY UPDATE `id`=id"). + WithArgs(int64(1), "cod_verification", "pending", 0, nil, sqlmock.AnyArg(), nil). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.EnqueueForTransaction(context.Background(), kdsBarTransaction(1, time.Now().AddDate(0, 0, -1)), domain.KdsNotificationKindCodVerification) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + // D22: a transaction paid on a later business day is enqueued as 'skipped' rather than left // unwritten, so the outbox still answers what happened to this order's notification. func TestEnqueueForTransaction_WritesSkippedStatusForAStaleTransaction(t *testing.T) { diff --git a/apps/api/data/mysql/migrations/000044_add_payment_verification.down.sql b/apps/api/data/mysql/migrations/000044_add_payment_verification.down.sql new file mode 100644 index 00000000..d3a257a3 --- /dev/null +++ b/apps/api/data/mysql/migrations/000044_add_payment_verification.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS `payment_verification_photos`; + +ALTER TABLE `payments` + DROP COLUMN `verified_at`, + DROP COLUMN `verification_status`; diff --git a/apps/api/data/mysql/migrations/000044_add_payment_verification.up.sql b/apps/api/data/mysql/migrations/000044_add_payment_verification.up.sql new file mode 100644 index 00000000..cb9a412f --- /dev/null +++ b/apps/api/data/mysql/migrations/000044_add_payment_verification.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE `payments` + ADD COLUMN `verification_status` VARCHAR(16) NULL AFTER `status`, + ADD COLUMN `verified_at` DATETIME NULL AFTER `verification_status`; + +CREATE TABLE `payment_verification_photos` ( + `payment_id` BIGINT NOT NULL, + `content_type` VARCHAR(32) NOT NULL, + `byte_size` INT NOT NULL, + `data` MEDIUMBLOB NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`payment_id`), + CONSTRAINT `fk_payment_verification_photos_payment` + FOREIGN KEY (`payment_id`) REFERENCES `payments` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/apps/api/data/mysql/payment_entity.go b/apps/api/data/mysql/payment_entity.go index 697904d5..1c5d2918 100644 --- a/apps/api/data/mysql/payment_entity.go +++ b/apps/api/data/mysql/payment_entity.go @@ -19,6 +19,8 @@ type Payment struct { PaidAt *time.Time CancelledAt *time.Time CancelReason *string + VerificationStatus *string + VerifiedAt *time.Time StatusCheckedAt *time.Time CreatedAt time.Time UpdatedAt time.Time diff --git a/apps/api/data/mysql/payment_repo.go b/apps/api/data/mysql/payment_repo.go index 888e91f6..3ba05626 100644 --- a/apps/api/data/mysql/payment_repo.go +++ b/apps/api/data/mysql/payment_repo.go @@ -113,8 +113,10 @@ func (repo Repository) GetPaymentsBySessionIdTotal(ctx context.Context, sessionI func (repo Repository) GetExpirablePayments(ctx context.Context, now time.Time, limit int) ([]domain.Payment, *domain.Error) { db := GetDbFromCtx(ctx, repo.db) + // FR-5: an approved COD payment is never expirable — the bar has started making it. query := db.Table("payments"). - Where("status = ? AND deleted_at IS NULL AND expired_at < ?", string(domain.PaymentStatePending), now). + Where("status = ? AND deleted_at IS NULL AND expired_at < ? AND (verification_status IS NULL OR verification_status <> ?)", + string(domain.PaymentStatePending), now, string(domain.PaymentVerificationStatusApproved)). Order("id ASC") if limit > 0 { @@ -148,6 +150,8 @@ func (repo Repository) UpdatePaymentById(ctx context.Context, payment domain.Pay "status": payload.Status, "qr_content": payload.QrContent, "paid_at": payload.PaidAt, + "verification_status": payload.VerificationStatus, + "verified_at": payload.VerifiedAt, "status_checked_at": payload.StatusCheckedAt, }); result.Error != nil { return domain.Payment{}, ToErrorCtx(ctx, result.Error, "UpdatePaymentById") diff --git a/apps/api/data/mysql/payment_repo_test.go b/apps/api/data/mysql/payment_repo_test.go index 0900e75a..4939d2c2 100644 --- a/apps/api/data/mysql/payment_repo_test.go +++ b/apps/api/data/mysql/payment_repo_test.go @@ -120,8 +120,8 @@ func TestPaymentRepository_GetExpirablePayments_FiltersToPendingPastExpiry(t *te repo, mock := newMockPaymentRepository(t) now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? ORDER BY id ASC LIMIT \\?"). - WithArgs("pending", now, 50). + mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? AND \\(verification_status IS NULL OR verification_status <> \\?\\) ORDER BY id ASC LIMIT \\?"). + WithArgs("pending", now, "approved", 50). WillReturnRows(sqlmock.NewRows([]string{"id"})) payments, err := repo.GetExpirablePayments(context.Background(), now, 50) @@ -135,8 +135,8 @@ func TestPaymentRepository_GetExpirablePayments_NoLimitFetchesEverything(t *test repo, mock := newMockPaymentRepository(t) now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? ORDER BY id ASC"). - WithArgs("pending", now). + mock.ExpectQuery("SELECT \\* FROM `payments` WHERE status = \\? AND deleted_at IS NULL AND expired_at < \\? AND \\(verification_status IS NULL OR verification_status <> \\?\\) ORDER BY id ASC"). + WithArgs("pending", now, "approved"). WillReturnRows(sqlmock.NewRows([]string{"id"})) _, err := repo.GetExpirablePayments(context.Background(), now, 0) @@ -144,3 +144,30 @@ func TestPaymentRepository_GetExpirablePayments_NoLimitFetchesEverything(t *test require.Nil(t, err) require.NoError(t, mock.ExpectationsWereMet()) } + +// FR-4/D2: approving a COD payment writes verification_status and verified_at through the same +// UpdatePaymentById every other payment transition uses. +func TestPaymentRepository_UpdatePaymentById_PersistsVerificationStatusAndVerifiedAt(t *testing.T) { + repo, mock := newMockPaymentRepository(t) + verifiedAt := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + verificationStatus := domain.PaymentVerificationStatusApproved + + mock.ExpectBegin() + mock.ExpectExec("UPDATE `payments` SET .*`verification_status`=\\?.*`verified_at`=\\?.* WHERE id = \\?"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + mock.ExpectQuery("SELECT \\* FROM `payments` WHERE id = \\? AND deleted_at IS NULL ORDER BY `payments`.`id` LIMIT \\?"). + WithArgs(int64(1), 1). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(1))) + + _, err := repo.UpdatePaymentById(context.Background(), domain.Payment{ + Id: 1, + Method: domain.PaymentMethodCod, + Status: domain.PaymentStatePending, + VerificationStatus: &verificationStatus, + VerifiedAt: &verifiedAt, + }, 1) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/apps/api/data/mysql/payment_transformer.go b/apps/api/data/mysql/payment_transformer.go index e436b79c..a1130d83 100644 --- a/apps/api/data/mysql/payment_transformer.go +++ b/apps/api/data/mysql/payment_transformer.go @@ -15,6 +15,12 @@ func ToPaymentDB(d domain.Payment) Payment { cancelReason = &reason } + var verificationStatus *string + if d.VerificationStatus != nil { + status := string(*d.VerificationStatus) + verificationStatus = &status + } + return Payment{ Id: d.Id, CartId: d.CartId, @@ -32,6 +38,8 @@ func ToPaymentDB(d domain.Payment) Payment { PaidAt: d.PaidAt, CancelledAt: d.CancelledAt, CancelReason: cancelReason, + VerificationStatus: verificationStatus, + VerifiedAt: d.VerifiedAt, StatusCheckedAt: d.StatusCheckedAt, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, @@ -63,6 +71,12 @@ func ToPaymentDomain(db Payment) domain.Payment { cancelReason = &reason } + var verificationStatus *domain.PaymentVerificationStatus + if db.VerificationStatus != nil { + status := domain.PaymentVerificationStatus(*db.VerificationStatus) + verificationStatus = &status + } + return domain.Payment{ Id: db.Id, CartId: db.CartId, @@ -80,6 +94,8 @@ func ToPaymentDomain(db Payment) domain.Payment { PaidAt: db.PaidAt, CancelledAt: db.CancelledAt, CancelReason: cancelReason, + VerificationStatus: verificationStatus, + VerifiedAt: db.VerifiedAt, StatusCheckedAt: db.StatusCheckedAt, CreatedAt: db.CreatedAt, UpdatedAt: db.UpdatedAt, diff --git a/apps/api/data/mysql/payment_transformer_test.go b/apps/api/data/mysql/payment_transformer_test.go index b87644ff..0ee55b5a 100644 --- a/apps/api/data/mysql/payment_transformer_test.go +++ b/apps/api/data/mysql/payment_transformer_test.go @@ -16,6 +16,8 @@ func TestPaymentTransformerRoundTrip(t *testing.T) { cancelledAt := time.Date(2026, 9, 10, 12, 4, 0, 0, time.UTC) cancelReason := domain.PaymentCancelReasonGuest statusCheckedAt := time.Date(2026, 9, 10, 12, 2, 55, 0, time.UTC) + verifiedAt := time.Date(2026, 9, 10, 12, 1, 30, 0, time.UTC) + verificationStatus := domain.PaymentVerificationStatusApproved payment := domain.Payment{ Id: 7, @@ -33,6 +35,8 @@ func TestPaymentTransformerRoundTrip(t *testing.T) { PaidAt: &paidAt, CancelledAt: &cancelledAt, CancelReason: &cancelReason, + VerificationStatus: &verificationStatus, + VerifiedAt: &verifiedAt, StatusCheckedAt: &statusCheckedAt, CreatedAt: time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC), UpdatedAt: time.Date(2026, 9, 10, 12, 3, 0, 0, time.UTC), @@ -54,3 +58,11 @@ func TestPaymentTransformerMapsNilCancelReasonToNull(t *testing.T) { assert.Nil(t, db.CancelReason) assert.Nil(t, mysql.ToPaymentDomain(db).CancelReason) } + +// VerificationStatus is nil for QRIS and cash (D2); only a COD payment ever sets it. +func TestPaymentTransformerMapsNilVerificationStatusToNull(t *testing.T) { + db := mysql.ToPaymentDB(domain.Payment{VerificationStatus: nil}) + + assert.Nil(t, db.VerificationStatus) + assert.Nil(t, mysql.ToPaymentDomain(db).VerificationStatus) +} diff --git a/apps/api/data/mysql/payment_verification_entity.go b/apps/api/data/mysql/payment_verification_entity.go new file mode 100644 index 00000000..1b134ae8 --- /dev/null +++ b/apps/api/data/mysql/payment_verification_entity.go @@ -0,0 +1,11 @@ +package mysql + +import "time" + +type PaymentVerificationPhoto struct { + PaymentId int64 `gorm:"primaryKey"` + ContentType string + ByteSize int + Data []byte + CreatedAt time.Time +} diff --git a/apps/api/data/mysql/payment_verification_repo.go b/apps/api/data/mysql/payment_verification_repo.go new file mode 100644 index 00000000..e976f369 --- /dev/null +++ b/apps/api/data/mysql/payment_verification_repo.go @@ -0,0 +1,55 @@ +package mysql + +import ( + "apps/api/domain" + "context" + + "gorm.io/gorm" +) + +func NewPaymentVerificationRepository(db *gorm.DB) domain.PaymentVerificationRepository { + return Repository{db: db} +} + +func (repo Repository) Create(ctx context.Context, photo domain.PaymentVerificationPhoto) *domain.Error { + db := GetDbFromCtx(ctx, repo.db) + payload := ToPaymentVerificationPhotoDB(photo) + result := db.Table("payment_verification_photos").Create(&payload) + return ToErrorCtx(ctx, result.Error, "Create") +} + +func (repo Repository) GetByPaymentId(ctx context.Context, paymentId int64) (domain.PaymentVerificationPhoto, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + var photo PaymentVerificationPhoto + result := db.Table("payment_verification_photos"). + Where("payment_id = ?", paymentId). + First(&photo) + return ToPaymentVerificationPhotoDomain(photo), ToErrorCtx(ctx, result.Error, "GetByPaymentId") +} + +func (repo Repository) DeleteByPaymentId(ctx context.Context, paymentId int64) *domain.Error { + db := GetDbFromCtx(ctx, repo.db) + result := db.Table("payment_verification_photos"). + Where("payment_id = ?", paymentId). + Delete(&PaymentVerificationPhoto{}) + return ToErrorCtx(ctx, result.Error, "DeleteByPaymentId") +} + +// DeleteOrphaned is the D5 backstop: it deletes photos whose payment is no longer cod + pending + +// awaiting (approved, cancelled, expired, or paid), bounded by limit per sweeper tick. +func (repo Repository) DeleteOrphaned(ctx context.Context, limit int) (int64, *domain.Error) { + db := GetDbFromCtx(ctx, repo.db) + + liveCodAwaitingPaymentIds := db.Table("payments"). + Select("id"). + Where("method = ? AND status = ? AND verification_status = ?", + string(domain.PaymentMethodCod), string(domain.PaymentStatePending), string(domain.PaymentVerificationStatusAwaiting)) + + query := db.Table("payment_verification_photos").Where("payment_id NOT IN (?)", liveCodAwaitingPaymentIds) + if limit > 0 { + query = query.Limit(limit) + } + + result := query.Delete(&PaymentVerificationPhoto{}) + return result.RowsAffected, ToErrorCtx(ctx, result.Error, "DeleteOrphaned") +} diff --git a/apps/api/data/mysql/payment_verification_repo_test.go b/apps/api/data/mysql/payment_verification_repo_test.go new file mode 100644 index 00000000..60dbb3d8 --- /dev/null +++ b/apps/api/data/mysql/payment_verification_repo_test.go @@ -0,0 +1,154 @@ +package mysql_test + +import ( + "apps/api/data/mysql" + "apps/api/domain" + "context" + "testing" + "time" + + sqlmock "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +func newMockPaymentVerificationRepository(t *testing.T) (domain.PaymentVerificationRepository, sqlmock.Sqlmock) { + t.Helper() + + sqlDB, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { sqlDB.Close() }) + + gormDB, err := gorm.Open(gormmysql.New(gormmysql.Config{ + Conn: sqlDB, + SkipInitializeWithVersion: true, + }), &gorm.Config{}) + require.NoError(t, err) + + return mysql.NewPaymentVerificationRepository(gormDB), mock +} + +func TestPaymentVerificationRepository_Create_InsertsThePhoto(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + createdAt := time.Now() + + mock.ExpectBegin() + mock.ExpectExec("INSERT INTO `payment_verification_photos` \\(`content_type`,`byte_size`,`data`,`created_at`,`payment_id`\\) VALUES \\(\\?,\\?,\\?,\\?,\\?\\)"). + WithArgs("image/jpeg", 3, []byte{1, 2, 3}, sqlmock.AnyArg(), int64(1)). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + + err := repo.Create(context.Background(), domain.PaymentVerificationPhoto{ + PaymentId: 1, + ContentType: "image/jpeg", + ByteSize: 3, + Data: []byte{1, 2, 3}, + CreatedAt: createdAt, + }) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPaymentVerificationRepository_GetByPaymentId_ReturnsTheStoredPhoto(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + createdAt := time.Now() + + mock.ExpectQuery("SELECT \\* FROM `payment_verification_photos` WHERE payment_id = \\? ORDER BY `payment_verification_photos`.`payment_id` LIMIT \\?"). + WithArgs(int64(1), 1). + WillReturnRows(sqlmock.NewRows([]string{"payment_id", "content_type", "byte_size", "data", "created_at"}). + AddRow(int64(1), "image/jpeg", 3, []byte{1, 2, 3}, createdAt)) + + photo, err := repo.GetByPaymentId(context.Background(), 1) + + require.Nil(t, err) + require.Equal(t, domain.PaymentVerificationPhoto{ + PaymentId: 1, + ContentType: "image/jpeg", + ByteSize: 3, + Data: []byte{1, 2, 3}, + CreatedAt: createdAt, + }, photo) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPaymentVerificationRepository_GetByPaymentId_NotFoundWhenThereIsNoRow(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + + mock.ExpectQuery("SELECT \\* FROM `payment_verification_photos` WHERE payment_id = \\? ORDER BY `payment_verification_photos`.`payment_id` LIMIT \\?"). + WithArgs(int64(404), 1). + WillReturnRows(sqlmock.NewRows([]string{"payment_id"})) + + _, err := repo.GetByPaymentId(context.Background(), 404) + + require.NotNil(t, err) + require.Equal(t, domain.NotFound, err.Type) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPaymentVerificationRepository_DeleteByPaymentId_DeletesTheRow(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `payment_verification_photos` WHERE payment_id = \\?"). + WithArgs(int64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + err := repo.DeleteByPaymentId(context.Background(), 1) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// D5/FR-3: a terminal path that never had a photo (a non-COD payment) still calls +// DeleteByPaymentId unconditionally, so a missing row must not be an error. +func TestPaymentVerificationRepository_DeleteByPaymentId_MissingRowIsNotAnError(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `payment_verification_photos` WHERE payment_id = \\?"). + WithArgs(int64(999)). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + err := repo.DeleteByPaymentId(context.Background(), 999) + + require.Nil(t, err) + require.NoError(t, mock.ExpectationsWereMet()) +} + +// D5 backstop: photos of a cancelled, expired, paid or approved payment are orphaned and swept; +// the awaiting one is the invariant's live case and must survive. +func TestPaymentVerificationRepository_DeleteOrphaned_DeletesPhotosOfDecidedPayments(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `payment_verification_photos` WHERE payment_id NOT IN \\(SELECT id FROM `payments` WHERE method = \\? AND status = \\? AND verification_status = \\?\\) LIMIT \\?"). + WithArgs("cod", "pending", "awaiting", 10). + WillReturnResult(sqlmock.NewResult(0, 4)) + mock.ExpectCommit() + + count, err := repo.DeleteOrphaned(context.Background(), 10) + + require.Nil(t, err) + require.Equal(t, int64(4), count) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestPaymentVerificationRepository_DeleteOrphaned_NoLimitDeletesEveryOrphan(t *testing.T) { + repo, mock := newMockPaymentVerificationRepository(t) + + mock.ExpectBegin() + mock.ExpectExec("DELETE FROM `payment_verification_photos` WHERE payment_id NOT IN \\(SELECT id FROM `payments` WHERE method = \\? AND status = \\? AND verification_status = \\?\\)"). + WithArgs("cod", "pending", "awaiting"). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectCommit() + + count, err := repo.DeleteOrphaned(context.Background(), 0) + + require.Nil(t, err) + require.Equal(t, int64(0), count) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/apps/api/data/mysql/payment_verification_transformer.go b/apps/api/data/mysql/payment_verification_transformer.go new file mode 100644 index 00000000..fe7ad046 --- /dev/null +++ b/apps/api/data/mysql/payment_verification_transformer.go @@ -0,0 +1,23 @@ +package mysql + +import "apps/api/domain" + +func ToPaymentVerificationPhotoDB(d domain.PaymentVerificationPhoto) PaymentVerificationPhoto { + return PaymentVerificationPhoto{ + PaymentId: d.PaymentId, + ContentType: d.ContentType, + ByteSize: d.ByteSize, + Data: d.Data, + CreatedAt: d.CreatedAt, + } +} + +func ToPaymentVerificationPhotoDomain(db PaymentVerificationPhoto) domain.PaymentVerificationPhoto { + return domain.PaymentVerificationPhoto{ + PaymentId: db.PaymentId, + ContentType: db.ContentType, + ByteSize: db.ByteSize, + Data: db.Data, + CreatedAt: db.CreatedAt, + } +} diff --git a/apps/api/data/mysql/transaction_entity.go b/apps/api/data/mysql/transaction_entity.go index f4464977..91f21c9f 100644 --- a/apps/api/data/mysql/transaction_entity.go +++ b/apps/api/data/mysql/transaction_entity.go @@ -35,26 +35,27 @@ type TransactionCoupon struct { } type Transaction struct { - Id int64 - CreatedAt time.Time - Name string - Source string - DiningOption string - CartId *int64 - Cart *Cart - PagerNumber int64 - TransactionNumber int64 - WalletId *int64 - Wallet *Wallet - Total float32 - TotalIncome float32 - TransactionItems []TransactionItem - TransactionCoupons []TransactionCoupon - PaidAmount float32 - PaidAt *time.Time - CompletedAt *time.Time - DeletedAt *time.Time - PaymentMethod *string `gorm:"->"` + Id int64 + CreatedAt time.Time + Name string + Source string + DiningOption string + CartId *int64 + Cart *Cart + PagerNumber int64 + TransactionNumber int64 + WalletId *int64 + Wallet *Wallet + Total float32 + TotalIncome float32 + TransactionItems []TransactionItem + TransactionCoupons []TransactionCoupon + PaidAmount float32 + PaidAt *time.Time + CompletedAt *time.Time + DeletedAt *time.Time + PaymentMethod *string `gorm:"->"` + PaymentVerificationStatus *string `gorm:"->"` } type TransactionStatistic struct { diff --git a/apps/api/data/mysql/transaction_repo.go b/apps/api/data/mysql/transaction_repo.go index 3b3e8202..3565008d 100644 --- a/apps/api/data/mysql/transaction_repo.go +++ b/apps/api/data/mysql/transaction_repo.go @@ -19,7 +19,7 @@ func (repo Repository) GetTransactionList(ctx context.Context, query string, sor var transactionResults []Transaction result := db.Table("transactions"). - Select("transactions.*, payments.method AS payment_method"). + Select("transactions.*, payments.method AS payment_method, payments.verification_status AS payment_verification_status"). Joins("LEFT JOIN payments ON payments.transaction_id = transactions.id AND payments.deleted_at IS NULL"). Where("transactions.deleted_at is NULL"). Preload("TransactionItems").Preload("TransactionItems.Values").Preload("TransactionItems.Variant").Preload("TransactionItems.Variant.VariantValues").Preload("TransactionItems.Variant.VariantValues.OptionValue").Preload("TransactionItems.Variant.Product").Preload("TransactionItems.Variant.Product.Category").Preload("TransactionCoupons").Preload("TransactionCoupons.Coupon").Preload("Wallet").Preload("Cart").Preload("Cart.Table").Order(fmt.Sprintf("%s %s", ToSortByColumn(sortBy), ToOrderColumn(order))) @@ -120,7 +120,7 @@ func (repo Repository) GetTransactionById(ctx context.Context, id int64) (domain var transaction Transaction result := db.Table("transactions"). - Select("transactions.*, payments.method AS payment_method"). + Select("transactions.*, payments.method AS payment_method, payments.verification_status AS payment_verification_status"). Joins("LEFT JOIN payments ON payments.transaction_id = transactions.id AND payments.deleted_at IS NULL"). Where("transactions.id = ?", id). Preload("TransactionItems").Preload("TransactionItems.Values").Preload("Wallet").Preload("Cart").Preload("Cart.Table").Preload("TransactionItems.Variant").Preload("TransactionItems.Variant.Materials").Preload("TransactionItems.Variant.Materials.Material").Preload("TransactionItems.Variant.VariantValues").Preload("TransactionItems.Variant.VariantValues.OptionValue").Preload("TransactionItems.Variant.Product").Preload("TransactionItems.Variant.Product.Category").Preload("TransactionCoupons").Preload("TransactionCoupons.Coupon").First(&transaction) diff --git a/apps/api/data/mysql/transaction_repo_test.go b/apps/api/data/mysql/transaction_repo_test.go index de320e12..7b2add2f 100644 --- a/apps/api/data/mysql/transaction_repo_test.go +++ b/apps/api/data/mysql/transaction_repo_test.go @@ -106,7 +106,7 @@ func TestTransactionRepository_GetTransactionList_Fulfillment(t *testing.T) { { name: "all applies no fulfillment predicate", fulfillment: nil, - expectedQuery: "SELECT transactions\\.\\*, payments\\.method AS payment_method FROM `transactions` LEFT JOIN payments ON payments\\.transaction_id = transactions\\.id AND payments\\.deleted_at IS NULL WHERE transactions\\.deleted_at is NULL", + expectedQuery: "SELECT transactions\\.\\*, payments\\.method AS payment_method, payments\\.verification_status AS payment_verification_status FROM `transactions` LEFT JOIN payments ON payments\\.transaction_id = transactions\\.id AND payments\\.deleted_at IS NULL WHERE transactions\\.deleted_at is NULL", expectedArgs: nil, }, } @@ -137,7 +137,7 @@ func TestTransactionRepository_GetTransactionList_Fulfillment(t *testing.T) { func TestTransactionRepository_GetTransactionById_JoinsPaymentMethod(t *testing.T) { repo, mock := newMockTransactionRepository(t) - mock.ExpectQuery("SELECT transactions\\.\\*, payments\\.method AS payment_method FROM `transactions` LEFT JOIN payments ON payments\\.transaction_id = transactions\\.id AND payments\\.deleted_at IS NULL WHERE transactions\\.id = \\?"). + mock.ExpectQuery("SELECT transactions\\.\\*, payments\\.method AS payment_method, payments\\.verification_status AS payment_verification_status FROM `transactions` LEFT JOIN payments ON payments\\.transaction_id = transactions\\.id AND payments\\.deleted_at IS NULL WHERE transactions\\.id = \\?"). WithArgs(int64(7), 1). WillReturnRows(sqlmock.NewRows([]string{"id"})) diff --git a/apps/api/data/mysql/transaction_transformer.go b/apps/api/data/mysql/transaction_transformer.go index 72bf32cc..7de66bee 100644 --- a/apps/api/data/mysql/transaction_transformer.go +++ b/apps/api/data/mysql/transaction_transformer.go @@ -57,27 +57,34 @@ func ToTransactionDomain(dbTransaction Transaction) domain.Transaction { paymentMethod = &m } + var paymentVerificationStatus *domain.PaymentVerificationStatus + if dbTransaction.PaymentVerificationStatus != nil { + s := domain.PaymentVerificationStatus(*dbTransaction.PaymentVerificationStatus) + paymentVerificationStatus = &s + } + return domain.Transaction{ - Id: dbTransaction.Id, - Name: dbTransaction.Name, - Source: domain.TransactionSource(dbTransaction.Source), - DiningOption: domain.DiningOption(dbTransaction.DiningOption), - CartId: dbTransaction.CartId, - Cart: cart, - PagerNumber: dbTransaction.PagerNumber, - TransactionNumber: dbTransaction.TransactionNumber, - WalletId: dbTransaction.WalletId, - Wallet: wallet, - Total: dbTransaction.Total, - TotalIncome: dbTransaction.TotalIncome, - PaidAmount: dbTransaction.PaidAmount, - PaidAt: dbTransaction.PaidAt, - CompletedAt: dbTransaction.CompletedAt, - DeletedAt: dbTransaction.DeletedAt, - CreatedAt: dbTransaction.CreatedAt, - TransactionItems: ToTransactionItemsListDomain(dbTransaction.TransactionItems), - TransactionCoupons: ToTransactionCouponsListDomain(dbTransaction.TransactionCoupons), - PaymentMethod: paymentMethod, + Id: dbTransaction.Id, + Name: dbTransaction.Name, + Source: domain.TransactionSource(dbTransaction.Source), + DiningOption: domain.DiningOption(dbTransaction.DiningOption), + CartId: dbTransaction.CartId, + Cart: cart, + PagerNumber: dbTransaction.PagerNumber, + TransactionNumber: dbTransaction.TransactionNumber, + WalletId: dbTransaction.WalletId, + Wallet: wallet, + Total: dbTransaction.Total, + TotalIncome: dbTransaction.TotalIncome, + PaidAmount: dbTransaction.PaidAmount, + PaidAt: dbTransaction.PaidAt, + CompletedAt: dbTransaction.CompletedAt, + DeletedAt: dbTransaction.DeletedAt, + CreatedAt: dbTransaction.CreatedAt, + TransactionItems: ToTransactionItemsListDomain(dbTransaction.TransactionItems), + TransactionCoupons: ToTransactionCouponsListDomain(dbTransaction.TransactionCoupons), + PaymentMethod: paymentMethod, + PaymentVerificationStatus: paymentVerificationStatus, } } diff --git a/apps/api/data/mysql/transaction_transformer_test.go b/apps/api/data/mysql/transaction_transformer_test.go index 23209a7c..3b527a65 100644 --- a/apps/api/data/mysql/transaction_transformer_test.go +++ b/apps/api/data/mysql/transaction_transformer_test.go @@ -53,3 +53,49 @@ func TestToTransactionDomain_PaymentMethod(t *testing.T) { func methodPtr(method domain.PaymentMethod) *domain.PaymentMethod { return &method } + +// paymentVerificationStatus is joined next to payment_method (D2): nil for POS and non-COD +// order transactions, and the COD payment's presence-axis status for a COD one. +func TestToTransactionDomain_PaymentVerificationStatus(t *testing.T) { + awaiting := "awaiting" + approved := "approved" + + tests := []struct { + name string + dbPaymentVerificationStatus *string + expected *domain.PaymentVerificationStatus + }{ + { + name: "a POS or non-COD transaction has no verification status", + dbPaymentVerificationStatus: nil, + expected: nil, + }, + { + name: "an unverified COD order reports awaiting", + dbPaymentVerificationStatus: &awaiting, + expected: verificationStatusPtr(domain.PaymentVerificationStatusAwaiting), + }, + { + name: "a verified COD order reports approved", + dbPaymentVerificationStatus: &approved, + expected: verificationStatusPtr(domain.PaymentVerificationStatusApproved), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + domainTransaction := mysql.ToTransactionDomain(mysql.Transaction{PaymentVerificationStatus: tt.dbPaymentVerificationStatus}) + + if tt.expected == nil { + require.Nil(t, domainTransaction.PaymentVerificationStatus) + } else { + require.NotNil(t, domainTransaction.PaymentVerificationStatus) + require.Equal(t, *tt.expected, *domainTransaction.PaymentVerificationStatus) + } + }) + } +} + +func verificationStatusPtr(status domain.PaymentVerificationStatus) *domain.PaymentVerificationStatus { + return &status +} diff --git a/apps/api/domain/guest_notification_usecase.go b/apps/api/domain/guest_notification_usecase.go index adff4417..baa2deae 100644 --- a/apps/api/domain/guest_notification_usecase.go +++ b/apps/api/domain/guest_notification_usecase.go @@ -98,7 +98,7 @@ func (usecase GuestNotificationUsecase) dispatchOne(ctx context.Context, notific accessKey = *payment.AccessKey } orderUrl := BuildOrderStatusUrl(usecase.orderWebBaseURL, payment.PartnerReferenceNo, accessKey) - message := BuildGuestWhatsappMessage(transaction, payment.Method, orderUrl) + message := BuildGuestWhatsappMessage(transaction, payment, orderUrl) result, sendErr := usecase.whatsappGateway.Send(ctx, WhatsAppMessage{To: *notification.WhatsappNumber, Body: message}) if sendErr != nil { diff --git a/apps/api/domain/guest_whatsapp_message.go b/apps/api/domain/guest_whatsapp_message.go index 94d7bd79..6b6040cd 100644 --- a/apps/api/domain/guest_whatsapp_message.go +++ b/apps/api/domain/guest_whatsapp_message.go @@ -7,20 +7,27 @@ import ( // BuildGuestWhatsappMessage is pure and re-derived at send time: a completed transaction cannot // be edited, so there is nothing to snapshot against. orderUrl is already built by -// BuildOrderStatusUrl; this function only shapes the message text (FR-6). -func BuildGuestWhatsappMessage(transaction Transaction, method PaymentMethod, orderUrl string) string { +// BuildOrderStatusUrl; this function only shapes the message text (FR-6, FR-10). +func BuildGuestWhatsappMessage(transaction Transaction, payment Payment, orderUrl string) string { sections := []string{ fmt.Sprintf("Halo *%s*, pesanan Anda sudah siap diambil! 🎉", transaction.Name), strings.Join([]string{ fmt.Sprintf("*No. Pesanan:* #%d", transaction.TransactionNumber), fmt.Sprintf("*Nama:* %s", transaction.Name), fmt.Sprintf("*Meja:* %s", guestOrderTableLabel(transaction)), - fmt.Sprintf("*Pembayaran:* %s", guestWhatsappPaymentMethodLabel(method)), + fmt.Sprintf("*Pembayaran:* %s", guestWhatsappPaymentMethodLabel(payment)), }, "\n"), strings.Join(append([]string{"*Pesanan:*"}, guestWhatsappItemLines(transaction)...), "\n"), + } + + if line := guestWhatsappPayAtPickupLine(transaction, payment); line != "" { + sections = append(sections, line) + } + + sections = append(sections, fmt.Sprintf("Tunjukkan halaman ini ke kasir untuk mengambil pesanan:\n%s", orderUrl), "Terima kasih!", - } + ) return strings.Join(sections, "\n\n") } @@ -45,11 +52,27 @@ func guestWhatsappItemLines(transaction Transaction) []string { return lines } -func guestWhatsappPaymentMethodLabel(method PaymentMethod) string { - if method == PaymentMethodCash { +func guestWhatsappPaymentMethodLabel(payment Payment) string { + switch payment.Method { + case PaymentMethodCash: return "Tunai" + case PaymentMethodCod: + if payment.Status == PaymentStatePaid { + return "COD" + } + return "COD — bayar di kasir" + default: + return "QRIS" + } +} + +// guestWhatsappPayAtPickupLine reminds a guest whose COD order is still unpaid to bring cash for +// pickup (FR-10); every other method, and a COD order already paid, add nothing here. +func guestWhatsappPayAtPickupLine(transaction Transaction, payment Payment) string { + if payment.Method != PaymentMethodCod || payment.Status == PaymentStatePaid { + return "" } - return "QRIS" + return fmt.Sprintf("Siapkan pembayaran *%s* saat mengambil pesanan di kasir.", formatRupiah(transaction.Total)) } func guestOrderTableLabel(transaction Transaction) string { diff --git a/apps/api/domain/guest_whatsapp_message_test.go b/apps/api/domain/guest_whatsapp_message_test.go index 80758ad4..8dba7cb7 100644 --- a/apps/api/domain/guest_whatsapp_message_test.go +++ b/apps/api/domain/guest_whatsapp_message_test.go @@ -33,7 +33,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { message := domain.BuildGuestWhatsappMessage( transaction, - domain.PaymentMethodQris, + domain.Payment{Method: domain.PaymentMethodQris}, "https://order.gatherloop.id/orders/ORD7K2M9QX4B1HZT?k=q3Vd0bX9pL2sR8tY1wZa7c", ) @@ -63,7 +63,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { Name: "Andi", } - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") assert.Contains(t, message, "#12") assert.NotContains(t, message, "#999") @@ -72,7 +72,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { t.Run("cash renders as Tunai", func(t *testing.T) { transaction := domain.Transaction{TransactionNumber: 1, Name: "Budi"} - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodCash, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodCash}, "https://order.example/o") assert.Contains(t, message, "*Pembayaran:* Tunai") }) @@ -80,11 +80,45 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { t.Run("qris renders as QRIS", func(t *testing.T) { transaction := domain.Transaction{TransactionNumber: 1, Name: "Budi"} - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") assert.Contains(t, message, "*Pembayaran:* QRIS") }) + t.Run("an unpaid COD order shows the COD label and a pay-at-pickup line with the total", func(t *testing.T) { + transaction := domain.Transaction{TransactionNumber: 12, Name: "Andi", Total: 45000} + + message := domain.BuildGuestWhatsappMessage( + transaction, + domain.Payment{Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending}, + "https://order.example/o", + ) + + assert.Contains(t, message, "*Pembayaran:* COD — bayar di kasir") + assert.Contains(t, message, "Siapkan pembayaran *Rp 45.000* saat mengambil pesanan di kasir.") + }) + + t.Run("a paid COD order has no pay-at-pickup line", func(t *testing.T) { + transaction := domain.Transaction{TransactionNumber: 12, Name: "Andi", Total: 45000} + + message := domain.BuildGuestWhatsappMessage( + transaction, + domain.Payment{Method: domain.PaymentMethodCod, Status: domain.PaymentStatePaid}, + "https://order.example/o", + ) + + assert.Contains(t, message, "*Pembayaran:* COD") + assert.NotContains(t, message, "Siapkan pembayaran") + }) + + t.Run("a non-COD order has no pay-at-pickup line", func(t *testing.T) { + transaction := domain.Transaction{TransactionNumber: 1, Name: "Budi", Total: 45000} + + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") + + assert.NotContains(t, message, "Siapkan pembayaran") + }) + t.Run("an item without options has no trailing dash segment", func(t *testing.T) { transaction := domain.Transaction{ TransactionNumber: 1, @@ -94,7 +128,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { }, } - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") assert.Contains(t, message, "1x Croissant") }) @@ -108,7 +142,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { }, } - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") assert.NotContains(t, message, "Catatan") }) @@ -122,7 +156,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { }, } - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") assert.Contains(t, message, "3x Croissant") }) @@ -136,7 +170,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { }, } - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/o") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/o") assert.Contains(t, message, "1.50x Kopi Susu (kg)") }) @@ -150,7 +184,7 @@ func TestBuildGuestWhatsappMessage(t *testing.T) { }, } - message := domain.BuildGuestWhatsappMessage(transaction, domain.PaymentMethodQris, "https://order.example/orders/ORD1?k=abc") + message := domain.BuildGuestWhatsappMessage(transaction, domain.Payment{Method: domain.PaymentMethodQris}, "https://order.example/orders/ORD1?k=abc") expected := "Halo *Citra*, pesanan Anda sudah siap diambil! 🎉\n" + "\n" + diff --git a/apps/api/domain/kds_notification_entity.go b/apps/api/domain/kds_notification_entity.go index 2e2e96e4..dce7c111 100644 --- a/apps/api/domain/kds_notification_entity.go +++ b/apps/api/domain/kds_notification_entity.go @@ -23,9 +23,10 @@ const KdsNotificationMaxAttempts = 5 type KdsNotificationKind string const ( - KdsNotificationKindOrderPaid KdsNotificationKind = "order_paid" - KdsNotificationKindCashPending KdsNotificationKind = "cash_pending" - KdsNotificationKindCashCancelled KdsNotificationKind = "cash_cancelled" + KdsNotificationKindOrderPaid KdsNotificationKind = "order_paid" + KdsNotificationKindCashPending KdsNotificationKind = "cash_pending" + KdsNotificationKindCashCancelled KdsNotificationKind = "cash_cancelled" + KdsNotificationKindCodVerification KdsNotificationKind = "cod_verification" ) type KdsStation string @@ -58,8 +59,9 @@ const kdsNotificationBodyItemLimit = 4 // BuildKdsPushMessage is pure and re-derived at send time (FR-6): a paid transaction cannot be // edited, so there is nothing to snapshot against. The kind picks the title/body pair (FR-7): -// order_paid is unchanged, cash_pending leads with the amount to collect, cash_cancelled retracts -// it. +// order_paid gains a pay-at-pickup line for an unpaid COD transaction (FR-9) but is otherwise +// unchanged, cash_pending leads with the amount to collect, cash_cancelled retracts it, and +// cod_verification asks a barista to look at the photo before anything is made. func BuildKdsPushMessage(transaction Transaction, kind KdsNotificationKind, sound string) KdsPushMessage { lines := StationLines(transaction) @@ -69,7 +71,7 @@ func BuildKdsPushMessage(transaction Transaction, kind KdsNotificationKind, soun } title := buildKdsNotificationTitle(transaction) - body := buildKdsNotificationBody(lines) + body := buildOrderPaidNotificationBody(transaction, lines) switch kind { case KdsNotificationKindCashPending: title = buildCashPendingNotificationTitle(transaction) @@ -77,6 +79,9 @@ func BuildKdsPushMessage(transaction Transaction, kind KdsNotificationKind, soun case KdsNotificationKindCashCancelled: title = buildCashCancelledNotificationTitle(transaction) body = cashCancelledNotificationBody + case KdsNotificationKindCodVerification: + title = buildCodVerificationNotificationTitle(transaction) + body = buildCodVerificationNotificationBody(transaction) } return KdsPushMessage{ @@ -124,6 +129,37 @@ func buildCashCancelledNotificationTitle(transaction Transaction) string { // job is telling a barista who may already be walking to the till to stand down. const cashCancelledNotificationBody = "Guest cancelled. Don't wait at the till." +func buildCodVerificationNotificationTitle(transaction Transaction) string { + return fmt.Sprintf("Verify COD order #%d — %s", transaction.TransactionNumber, kdsOrderSubject(transaction)) +} + +// buildCodVerificationNotificationBody leads with the instruction to check the photo, not the +// items (D15): this push asks for a presence decision, not preparation — nothing is made until +// order_paid follows approval. +func buildCodVerificationNotificationBody(transaction Transaction) string { + return fmt.Sprintf("Check the photo in the POS · %s", formatRupiah(transaction.Total)) +} + +// buildOrderPaidNotificationBody appends a pay-at-pickup reminder for a still-unpaid COD +// transaction (FR-9): order_paid is the "start making it" signal for every method, but COD is the +// only one where approval, not payment, triggers it. +func buildOrderPaidNotificationBody(transaction Transaction, lines []KdsStationLine) string { + body := buildKdsNotificationBody(lines) + if !isUnpaidCod(transaction) { + return body + } + + pickupLine := fmt.Sprintf("COD — collect %s at pickup", formatRupiah(transaction.Total)) + if body == "" { + return pickupLine + } + return body + " · " + pickupLine +} + +func isUnpaidCod(transaction Transaction) bool { + return transaction.PaymentMethod != nil && *transaction.PaymentMethod == PaymentMethodCod && transaction.PaidAt == nil +} + func kdsOrderSubject(transaction Transaction) string { if transaction.Source == TransactionSourceOrder && transaction.Cart != nil && transaction.Cart.Table != nil { return transaction.Cart.Table.Label diff --git a/apps/api/domain/kds_notification_entity_test.go b/apps/api/domain/kds_notification_entity_test.go index 0b98b738..38ec323c 100644 --- a/apps/api/domain/kds_notification_entity_test.go +++ b/apps/api/domain/kds_notification_entity_test.go @@ -3,6 +3,7 @@ package domain_test import ( "apps/api/domain" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -227,4 +228,122 @@ func TestBuildKdsPushMessage(t *testing.T) { assert.Equal(t, "New order #12 — Meja 4", message.Title) assert.Equal(t, "BAR: 1× Kopi Susu", message.Body) }) + + t.Run("a cod_verification title and body ask for a decision, table included", func(t *testing.T) { + transaction := domain.Transaction{ + TransactionNumber: 12, + Source: domain.TransactionSourceOrder, + Cart: &domain.Cart{Table: &domain.Table{Label: "Meja 4"}}, + Total: 45000, + TransactionItems: []domain.TransactionItem{ + kdsItem("BAR", 1, "Kopi Susu"), + }, + } + + message := domain.BuildKdsPushMessage(transaction, domain.KdsNotificationKindCodVerification, "default") + + assert.Equal(t, "Verify COD order #12 — Meja 4", message.Title) + assert.Equal(t, "Check the photo in the POS · Rp 45.000", message.Body) + assert.Equal(t, "cod_verification", message.Data["kind"]) + }) + + t.Run("a cod_verification notification with no station items still names the amount", func(t *testing.T) { + transaction := domain.Transaction{ + TransactionNumber: 12, + Source: domain.TransactionSourceOrder, + Cart: &domain.Cart{Table: &domain.Table{Label: "Meja 4"}}, + Total: 25000, + TransactionItems: []domain.TransactionItem{ + { + Amount: 1, + ProductName: "Board Game Ticket", + Variant: domain.Variant{Product: domain.Product{Category: domain.Category{Station: "NONE"}}}, + }, + }, + } + + message := domain.BuildKdsPushMessage(transaction, domain.KdsNotificationKindCodVerification, "default") + + assert.Equal(t, "Check the photo in the POS · Rp 25.000", message.Body) + }) + + t.Run("order_paid for an unpaid COD transaction gains a pay-at-pickup line", func(t *testing.T) { + codMethod := domain.PaymentMethodCod + transaction := domain.Transaction{ + TransactionNumber: 12, + Source: domain.TransactionSourceOrder, + Cart: &domain.Cart{Table: &domain.Table{Label: "Meja 4"}}, + Total: 45000, + PaymentMethod: &codMethod, + TransactionItems: []domain.TransactionItem{ + kdsItem("BAR", 1, "Kopi Susu"), + }, + } + + message := domain.BuildKdsPushMessage(transaction, domain.KdsNotificationKindOrderPaid, "default") + + assert.Equal(t, "BAR: 1× Kopi Susu · COD — collect Rp 45.000 at pickup", message.Body) + }) + + t.Run("order_paid for an unpaid COD transaction with no station items still names the pickup amount", func(t *testing.T) { + codMethod := domain.PaymentMethodCod + transaction := domain.Transaction{ + TransactionNumber: 12, + Source: domain.TransactionSourceOrder, + Cart: &domain.Cart{Table: &domain.Table{Label: "Meja 4"}}, + Total: 25000, + PaymentMethod: &codMethod, + TransactionItems: []domain.TransactionItem{ + { + Amount: 1, + ProductName: "Board Game Ticket", + Variant: domain.Variant{Product: domain.Product{Category: domain.Category{Station: "NONE"}}}, + }, + }, + } + + message := domain.BuildKdsPushMessage(transaction, domain.KdsNotificationKindOrderPaid, "default") + + assert.Equal(t, "COD — collect Rp 25.000 at pickup", message.Body) + }) + + t.Run("order_paid for a paid COD transaction carries no pickup line", func(t *testing.T) { + codMethod := domain.PaymentMethodCod + paidAt := time.Now() + transaction := domain.Transaction{ + TransactionNumber: 12, + Source: domain.TransactionSourceOrder, + Cart: &domain.Cart{Table: &domain.Table{Label: "Meja 4"}}, + Total: 45000, + PaymentMethod: &codMethod, + PaidAt: &paidAt, + TransactionItems: []domain.TransactionItem{ + kdsItem("BAR", 1, "Kopi Susu"), + }, + } + + message := domain.BuildKdsPushMessage(transaction, domain.KdsNotificationKindOrderPaid, "default") + + assert.Equal(t, "BAR: 1× Kopi Susu", message.Body) + }) + + t.Run("order_paid for QRIS and cash is unchanged by the COD pickup-line addition", func(t *testing.T) { + for _, method := range []domain.PaymentMethod{domain.PaymentMethodQris, domain.PaymentMethodCash} { + method := method + transaction := domain.Transaction{ + TransactionNumber: 12, + Source: domain.TransactionSourceOrder, + Cart: &domain.Cart{Table: &domain.Table{Label: "Meja 4"}}, + Total: 45000, + PaymentMethod: &method, + TransactionItems: []domain.TransactionItem{ + kdsItem("BAR", 1, "Kopi Susu"), + }, + } + + message := domain.BuildKdsPushMessage(transaction, domain.KdsNotificationKindOrderPaid, "default") + + assert.Equal(t, "BAR: 1× Kopi Susu", message.Body) + } + }) } diff --git a/apps/api/domain/kds_notification_repository.go b/apps/api/domain/kds_notification_repository.go index deff467c..4ee5d0be 100644 --- a/apps/api/domain/kds_notification_repository.go +++ b/apps/api/domain/kds_notification_repository.go @@ -41,10 +41,11 @@ type KdsNotificationRepository interface { // EnqueueForTransaction writes one row per (transaction, kind), and is a no-op on a // transaction already enqueued for that kind (D4/D8) — never an error. For order_paid, it // writes only when ShouldNotify is true (D24), 'skipped' instead of 'pending' when - // IsStaleForNotification is true (D22). cash_pending and cash_cancelled bypass both checks - // (D9): cash_pending exists to move someone to the till, not to make a drink, and is enqueued - // the instant the transaction is created, so staleness can never apply to it; cash_cancelled - // retracts that instruction (D16). + // IsStaleForNotification is true (D22). cash_pending, cash_cancelled and cod_verification + // bypass both checks (D9, FR-9): none of them is a "start making it" signal, so the station + // rule doesn't gate them, and each is enqueued the instant the transaction exists, so + // staleness can never apply. cash_cancelled retracts cash_pending's instruction (D16); + // cod_verification asks a barista to look at a photo before anything is made. EnqueueForTransaction(ctx context.Context, transaction Transaction, kind KdsNotificationKind) *Error // HasNotificationForTransaction reports whether a (transaction, kind) row already exists. // FR-7 uses it to skip cash_cancelled when the transaction never got a cash_pending push in diff --git a/apps/api/domain/payment_entity.go b/apps/api/domain/payment_entity.go index 8baefc95..b215994a 100644 --- a/apps/api/domain/payment_entity.go +++ b/apps/api/domain/payment_entity.go @@ -51,19 +51,30 @@ type PaymentMethod string const ( PaymentMethodQris PaymentMethod = "qris" PaymentMethodCash PaymentMethod = "cash" + PaymentMethodCod PaymentMethod = "cod" ) func ParsePaymentMethod(method string) (PaymentMethod, *Error) { switch PaymentMethod(method) { case "": return PaymentMethodQris, nil - case PaymentMethodQris, PaymentMethodCash: + case PaymentMethodQris, PaymentMethodCash, PaymentMethodCod: return PaymentMethod(method), nil default: return "", &Error{Type: BadRequest, Message: "unknown payment method"} } } +// PaymentVerificationStatus is the presence axis (D2): whether a barista has confirmed the guest +// is in the café, independent of payments.status (has the money been collected). It is nil for +// every method but cod. +type PaymentVerificationStatus string + +const ( + PaymentVerificationStatusAwaiting PaymentVerificationStatus = "awaiting" + PaymentVerificationStatusApproved PaymentVerificationStatus = "approved" +) + type PaymentState string const ( @@ -79,6 +90,7 @@ type PaymentCancelReason string const ( PaymentCancelReasonGuest PaymentCancelReason = "guest" PaymentCancelReasonSuperseded PaymentCancelReason = "superseded" + PaymentCancelReasonRejected PaymentCancelReason = "rejected" ) type ConfirmPaymentOutcome string @@ -111,6 +123,8 @@ type Payment struct { PaidAt *time.Time CancelledAt *time.Time CancelReason *PaymentCancelReason + VerificationStatus *PaymentVerificationStatus + VerifiedAt *time.Time StatusCheckedAt *time.Time CreatedAt time.Time UpdatedAt time.Time @@ -121,20 +135,39 @@ func (payment Payment) IsAwaitingPayment(now time.Time) bool { return payment.Status == PaymentStatePending && now.Before(payment.ExpiredAt) } -// CanBeCancelledBy reports only the payment's own eligibility (pending, owned by sessionId); -// the feature flag is applied by the use case, not here (D10). +// CanBeCancelledBy reports only the payment's own eligibility (pending, owned by sessionId, and +// not an approved COD order the bar has already started making); the feature flag is applied by +// the use case, not here (D10). func (payment Payment) CanBeCancelledBy(sessionId string, now time.Time) bool { - return payment.Status == PaymentStatePending && payment.SessionId == sessionId + return payment.Status == PaymentStatePending && payment.SessionId == sessionId && !payment.isVerificationApproved() } func (payment Payment) RequiresGateway() bool { return payment.Method == PaymentMethodQris } +// IsExpirable is FR-5's single predicate for whether the sweeper or a guest's own status poll may +// give up on this payment: an approved COD order is being made and must never expire, even past +// its own expired_at. +func (payment Payment) IsExpirable() bool { + return payment.Status == PaymentStatePending && !payment.isVerificationApproved() +} + +// IsAwaitingCodVerification is FR-5's guard for PayTransaction and CompleteTransaction: nothing +// should be paid for or marked ready before a barista has confirmed the guest is in the café. +func (payment Payment) IsAwaitingCodVerification() bool { + return payment.VerificationStatus != nil && *payment.VerificationStatus == PaymentVerificationStatusAwaiting +} + +func (payment Payment) isVerificationApproved() bool { + return payment.VerificationStatus != nil && *payment.VerificationStatus == PaymentVerificationStatusApproved +} + type PaymentSummary struct { PartnerReferenceNo string Status PaymentState Method PaymentMethod + VerificationStatus *PaymentVerificationStatus TransactionNumber int64 CustomerName string TableLabel string @@ -151,6 +184,7 @@ func ToPaymentSummary(payment Payment, transaction TransactionSummary) PaymentSu PartnerReferenceNo: payment.PartnerReferenceNo, Status: payment.Status, Method: payment.Method, + VerificationStatus: payment.VerificationStatus, TransactionNumber: transaction.TransactionNumber, CustomerName: transaction.Name, TableLabel: transaction.TableLabel, diff --git a/apps/api/domain/payment_entity_test.go b/apps/api/domain/payment_entity_test.go index bf504111..1de9b5a5 100644 --- a/apps/api/domain/payment_entity_test.go +++ b/apps/api/domain/payment_entity_test.go @@ -70,12 +70,15 @@ func TestPaymentIsAwaitingPayment(t *testing.T) { func TestPaymentCanBeCancelledBy(t *testing.T) { now := time.Date(2026, 9, 10, 12, 0, 0, 0, time.UTC) + awaiting := domain.PaymentVerificationStatusAwaiting + approved := domain.PaymentVerificationStatusApproved testCases := []struct { - name string - status domain.PaymentState - sessionId string - expected bool + name string + status domain.PaymentState + sessionId string + verificationStatus *domain.PaymentVerificationStatus + expected bool }{ { name: "owner and pending", @@ -113,17 +116,74 @@ func TestPaymentCanBeCancelledBy(t *testing.T) { sessionId: "session-1", expected: false, }, + { + name: "pending COD still awaiting verification", + status: domain.PaymentStatePending, + sessionId: "session-1", + verificationStatus: &awaiting, + expected: true, + }, + { + // FR-5/D10: the bar has started making an approved COD order — the guest may no + // longer back out, even though the money is still pending. + name: "pending COD already approved", + status: domain.PaymentStatePending, + sessionId: "session-1", + verificationStatus: &approved, + expected: false, + }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - payment := domain.Payment{Status: testCase.status, SessionId: "session-1"} + payment := domain.Payment{Status: testCase.status, SessionId: "session-1", VerificationStatus: testCase.verificationStatus} assert.Equal(t, testCase.expected, payment.CanBeCancelledBy(testCase.sessionId, now)) }) } } +func TestPaymentIsExpirable(t *testing.T) { + awaiting := domain.PaymentVerificationStatusAwaiting + approved := domain.PaymentVerificationStatusApproved + + testCases := []struct { + name string + status domain.PaymentState + verificationStatus *domain.PaymentVerificationStatus + expected bool + }{ + {name: "pending, no verification (qris/cash)", status: domain.PaymentStatePending, expected: true}, + {name: "pending COD awaiting", status: domain.PaymentStatePending, verificationStatus: &awaiting, expected: true}, + { + // FR-5: this is the single predicate the sweeper and the guest's own poll ask — + // an approved COD order is being made and must never expire. + name: "pending COD approved", status: domain.PaymentStatePending, verificationStatus: &approved, expected: false, + }, + {name: "paid", status: domain.PaymentStatePaid, expected: false}, + {name: "expired", status: domain.PaymentStateExpired, expected: false}, + {name: "cancelled", status: domain.PaymentStateCancelled, expected: false}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + payment := domain.Payment{Status: testCase.status, VerificationStatus: testCase.verificationStatus} + + assert.Equal(t, testCase.expected, payment.IsExpirable()) + }) + } +} + +func TestPaymentIsAwaitingCodVerification(t *testing.T) { + awaiting := domain.PaymentVerificationStatusAwaiting + approved := domain.PaymentVerificationStatusApproved + + assert.True(t, domain.Payment{Method: domain.PaymentMethodCod, VerificationStatus: &awaiting}.IsAwaitingCodVerification()) + assert.False(t, domain.Payment{Method: domain.PaymentMethodCod, VerificationStatus: &approved}.IsAwaitingCodVerification()) + assert.False(t, domain.Payment{Method: domain.PaymentMethodCod, VerificationStatus: nil}.IsAwaitingCodVerification()) + assert.False(t, domain.Payment{Method: domain.PaymentMethodQris}.IsAwaitingCodVerification()) +} + func TestParsePaymentMethod(t *testing.T) { testCases := []struct { name string @@ -133,6 +193,7 @@ func TestParsePaymentMethod(t *testing.T) { }{ {name: "qris is valid", input: "qris", expectedMethod: domain.PaymentMethodQris, expectError: false}, {name: "cash is valid", input: "cash", expectedMethod: domain.PaymentMethodCash, expectError: false}, + {name: "cod is valid", input: "cod", expectedMethod: domain.PaymentMethodCod, expectError: false}, {name: "an empty string defaults to qris", input: "", expectedMethod: domain.PaymentMethodQris, expectError: false}, {name: "an unknown value is rejected", input: "credit_card", expectError: true}, } @@ -155,6 +216,7 @@ func TestParsePaymentMethod(t *testing.T) { func TestPaymentRequiresGateway(t *testing.T) { assert.True(t, domain.Payment{Method: domain.PaymentMethodQris}.RequiresGateway()) assert.False(t, domain.Payment{Method: domain.PaymentMethodCash}.RequiresGateway()) + assert.False(t, domain.Payment{Method: domain.PaymentMethodCod}.RequiresGateway()) } var partnerReferenceNoPattern = regexp.MustCompile(`^ORD[0-9A-HJKMNP-TV-Z]{13}$`) diff --git a/apps/api/domain/payment_usecase.go b/apps/api/domain/payment_usecase.go index 4a6394c2..235c1190 100644 --- a/apps/api/domain/payment_usecase.go +++ b/apps/api/domain/payment_usecase.go @@ -3,6 +3,7 @@ package domain import ( "context" "crypto/subtle" + "encoding/base64" "log/slog" "strings" "time" @@ -14,22 +15,30 @@ const statusRequeryFloor = 1 * time.Second // kdsDispatchBatchSize. const paymentExpiryBatchSize = 50 +// FR-6/D5: the DeleteOrphaned backstop's own bound, so one sweeper tick cannot run unbounded +// either — it should always delete zero rows, so the bound only matters when the invariant has +// already broken. +const paymentVerificationOrphanBatchSize = 50 + type PaymentUsecase struct { - paymentRepository PaymentRepository - paymentGatewayRepository PaymentGatewayRepository - customerRepository CustomerRepository - cartRepository CartRepository - transactionRepository TransactionRepository - variantRepository VariantRepository - walletRepository WalletRepository - availabilityReservation AvailabilityReservation - kdsNotificationRepository KdsNotificationRepository - kdsNotificationDispatcher KdsNotificationDispatcher - whatsappNumberVerifier WhatsappNumberVerifier - qrisExpirySeconds int - cashExpirySeconds int - orderPaymentWalletId int64 - orderPaymentCancelEnabled bool + paymentRepository PaymentRepository + paymentGatewayRepository PaymentGatewayRepository + customerRepository CustomerRepository + cartRepository CartRepository + transactionRepository TransactionRepository + variantRepository VariantRepository + walletRepository WalletRepository + availabilityReservation AvailabilityReservation + kdsNotificationRepository KdsNotificationRepository + kdsNotificationDispatcher KdsNotificationDispatcher + whatsappNumberVerifier WhatsappNumberVerifier + paymentVerificationRepository PaymentVerificationRepository + qrisExpirySeconds int + cashExpirySeconds int + codVerificationExpirySeconds int + orderPaymentWalletId int64 + orderPaymentCancelEnabled bool + orderCodPaymentEnabled bool } func NewPaymentUsecase( @@ -44,35 +53,45 @@ func NewPaymentUsecase( kdsNotificationRepository KdsNotificationRepository, kdsNotificationDispatcher KdsNotificationDispatcher, whatsappNumberVerifier WhatsappNumberVerifier, + paymentVerificationRepository PaymentVerificationRepository, qrisExpirySeconds int, cashExpirySeconds int, + codVerificationExpirySeconds int, orderPaymentWalletId int64, orderPaymentCancelEnabled bool, + orderCodPaymentEnabled bool, ) PaymentUsecase { return PaymentUsecase{ - paymentRepository: paymentRepository, - paymentGatewayRepository: paymentGatewayRepository, - customerRepository: customerRepository, - cartRepository: cartRepository, - transactionRepository: transactionRepository, - variantRepository: variantRepository, - walletRepository: walletRepository, - availabilityReservation: availabilityReservation, - kdsNotificationRepository: kdsNotificationRepository, - kdsNotificationDispatcher: kdsNotificationDispatcher, - whatsappNumberVerifier: whatsappNumberVerifier, - qrisExpirySeconds: qrisExpirySeconds, - cashExpirySeconds: cashExpirySeconds, - orderPaymentWalletId: orderPaymentWalletId, - orderPaymentCancelEnabled: orderPaymentCancelEnabled, + paymentRepository: paymentRepository, + paymentGatewayRepository: paymentGatewayRepository, + customerRepository: customerRepository, + cartRepository: cartRepository, + transactionRepository: transactionRepository, + variantRepository: variantRepository, + walletRepository: walletRepository, + availabilityReservation: availabilityReservation, + kdsNotificationRepository: kdsNotificationRepository, + kdsNotificationDispatcher: kdsNotificationDispatcher, + whatsappNumberVerifier: whatsappNumberVerifier, + paymentVerificationRepository: paymentVerificationRepository, + qrisExpirySeconds: qrisExpirySeconds, + cashExpirySeconds: cashExpirySeconds, + codVerificationExpirySeconds: codVerificationExpirySeconds, + orderPaymentWalletId: orderPaymentWalletId, + orderPaymentCancelEnabled: orderPaymentCancelEnabled, + orderCodPaymentEnabled: orderCodPaymentEnabled, } } func (usecase PaymentUsecase) expirySecondsFor(method PaymentMethod) int { - if method == PaymentMethodCash { + switch method { + case PaymentMethodCash: return usecase.cashExpirySeconds + case PaymentMethodCod: + return usecase.codVerificationExpirySeconds + default: + return usecase.qrisExpirySeconds } - return usecase.qrisExpirySeconds } func (usecase PaymentUsecase) validateOrderPaymentWallet(ctx context.Context) *Error { @@ -86,10 +105,34 @@ func (usecase PaymentUsecase) validateOrderPaymentWallet(ctx context.Context) *E return ValidateOrderPaymentWallet(wallet) } -func (usecase PaymentUsecase) Checkout(ctx context.Context, sessionId string, customerName string, customerWhatsappNumber string, method PaymentMethod, diningOption DiningOption) (Payment, Transaction, *Error) { +func (usecase PaymentUsecase) Checkout(ctx context.Context, sessionId string, customerName string, customerWhatsappNumber string, method PaymentMethod, diningOption DiningOption, verificationPhoto string) (Payment, Transaction, *Error) { var resultPayment Payment var resultTransaction Transaction - enqueuedCashPendingNotification := false + enqueuedNonGatewayNotification := false + + // FR-2: the gate and the photo decode/sniff are pure and local — they run before + // BeginTransaction and before the WhatsApp check below, so a disabled trial or a bad photo + // never holds a row lock or reaches Fonnte. + var verificationPhotoBytes []byte + var verificationPhotoContentType string + if method == PaymentMethodCod { + if !usecase.orderCodPaymentEnabled { + return Payment{}, Transaction{}, &Error{Type: BadRequest, Message: "payment method is not available"} + } + + decoded, decodeErr := base64.StdEncoding.DecodeString(verificationPhoto) + if decodeErr != nil { + return Payment{}, Transaction{}, &Error{Type: BadRequest, Message: "verification photo must be valid base64"} + } + + contentType, validateErr := ValidateVerificationPhoto(decoded) + if validateErr != nil { + return Payment{}, Transaction{}, validateErr + } + + verificationPhotoBytes = decoded + verificationPhotoContentType = contentType + } // FR-2/D5: absent (empty) is left nil so the customer's stored number and an idempotent // pending payment's snapshot stay unchanged; only a non-empty value is normalized and @@ -224,6 +267,15 @@ func (usecase PaymentUsecase) Checkout(ctx context.Context, sessionId string, cu expiredAt := time.Now().Add(time.Duration(usecase.expirySecondsFor(method)) * time.Second) + // D2: verification is the presence axis, orthogonal to status — a cod payment starts + // pending + awaiting, exactly like every other axis pairing this domain snapshots at + // creation. + var verificationStatus *PaymentVerificationStatus + if method == PaymentMethodCod { + awaiting := PaymentVerificationStatusAwaiting + verificationStatus = &awaiting + } + createdPayment, err := usecase.paymentRepository.CreatePayment(ctxWithTx, Payment{ CartId: cart.Id, SessionId: sessionId, @@ -235,6 +287,7 @@ func (usecase PaymentUsecase) Checkout(ctx context.Context, sessionId string, cu Status: PaymentStatePending, Amount: total, ExpiredAt: expiredAt, + VerificationStatus: verificationStatus, }) if err != nil { return err @@ -243,13 +296,30 @@ func (usecase PaymentUsecase) Checkout(ctx context.Context, sessionId string, cu resultPayment = createdPayment resultTransaction = createdTransaction + if method == PaymentMethodCod { + // FR-3: the photo row is created in the same transaction as the payment, keeping D5's + // invariant true from the instant the payment exists. + if createErr := usecase.paymentVerificationRepository.Create(ctxWithTx, PaymentVerificationPhoto{ + PaymentId: createdPayment.Id, + ContentType: verificationPhotoContentType, + ByteSize: len(verificationPhotoBytes), + Data: verificationPhotoBytes, + }); createErr != nil { + return createErr + } + } + if !createdPayment.RequiresGateway() { - // FR-5: buzzes the KDS before any money moves, so a barista walks to the till - // rather than the guest arriving to an unstaffed one. - if enqueueErr := usecase.kdsNotificationRepository.EnqueueForTransaction(ctxWithTx, createdTransaction, KdsNotificationKindCashPending); enqueueErr != nil { + // FR-5/FR-9: buzzes the KDS before any money moves, so a barista walks to the till (cash) + // or looks at the photo (cod) rather than the guest arriving to nothing happening. + kind := KdsNotificationKindCashPending + if method == PaymentMethodCod { + kind = KdsNotificationKindCodVerification + } + if enqueueErr := usecase.kdsNotificationRepository.EnqueueForTransaction(ctxWithTx, createdTransaction, kind); enqueueErr != nil { return enqueueErr } - enqueuedCashPendingNotification = true + enqueuedNonGatewayNotification = true return nil } @@ -276,7 +346,7 @@ func (usecase PaymentUsecase) Checkout(ctx context.Context, sessionId string, cu // FR-5: kicked after the commit, exactly as PayTransaction and ConfirmPayment already do — // the guest's HTTP response never waits on Expo. - if err == nil && enqueuedCashPendingNotification { + if err == nil && enqueuedNonGatewayNotification { usecase.kdsNotificationDispatcher.TriggerDispatch() } @@ -382,7 +452,7 @@ func (usecase PaymentUsecase) applyQrisStatus(ctxWithTx context.Context, payment // D7: the guest may have checked out again on this cart while the first payment sat // cancelled or expired — finalise that newer attempt so the cart never ends up with two // paid orders. - if supersedeErr := supersedeLivePayments(ctxWithTx, payment.CartId, payment.Id, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository); supersedeErr != nil { + if supersedeErr := supersedeLivePayments(ctxWithTx, payment.CartId, payment.Id, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository, usecase.paymentVerificationRepository); supersedeErr != nil { return payment, "", supersedeErr } @@ -397,7 +467,7 @@ func (usecase PaymentUsecase) applyQrisStatus(ctxWithTx context.Context, payment outcome = ConfirmPaymentOutcomeFailed } - updatedPayment, finalizeErr := finalizeUncollectedPayment(ctxWithTx, payment, terminalStatus, nil, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository) + updatedPayment, finalizeErr := finalizeUncollectedPayment(ctxWithTx, payment, terminalStatus, nil, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository, usecase.paymentVerificationRepository) if finalizeErr != nil { return payment, "", finalizeErr } @@ -414,8 +484,10 @@ func (usecase PaymentUsecase) applyQrisStatus(ctxWithTx context.Context, payment // reason is non-nil only when terminalStatus is PaymentStateCancelled (D9); it is a free function, // not a PaymentUsecase method, so TransactionUsecase's settleOrderPayment can share it too. A cash // payment that lands in cancelled — whether the guest's own cancel or a supersede (D7) — also gets -// the KDS retraction (D16/FR-7). -func finalizeUncollectedPayment(ctxWithTx context.Context, payment Payment, terminalStatus PaymentState, reason *PaymentCancelReason, paymentRepository PaymentRepository, transactionRepository TransactionRepository, availabilityReservation AvailabilityReservation, kdsNotificationRepository KdsNotificationRepository) (Payment, *Error) { +// the KDS retraction (D16/FR-7). FR-6/D5: the verification photo delete is unconditional — a +// no-op for every method but cod, and for a cod payment that already lost its photo — so every +// exit from "cod, pending, awaiting" keeps the storage invariant without a method check here. +func finalizeUncollectedPayment(ctxWithTx context.Context, payment Payment, terminalStatus PaymentState, reason *PaymentCancelReason, paymentRepository PaymentRepository, transactionRepository TransactionRepository, availabilityReservation AvailabilityReservation, kdsNotificationRepository KdsNotificationRepository, paymentVerificationRepository PaymentVerificationRepository) (Payment, *Error) { now := time.Now() payment.Status = terminalStatus payment.StatusCheckedAt = &now @@ -429,6 +501,10 @@ func finalizeUncollectedPayment(ctxWithTx context.Context, payment Payment, term return payment, updateErr } + if deleteErr := paymentVerificationRepository.DeleteByPaymentId(ctxWithTx, payment.Id); deleteErr != nil { + return payment, deleteErr + } + if payment.TransactionId != nil { transaction, txErr := transactionRepository.GetTransactionById(ctxWithTx, *payment.TransactionId) if txErr != nil { @@ -473,7 +549,7 @@ func enqueueCashCancelledNotification(ctxWithTx context.Context, transaction Tra // one that was just paid late: a cart becomes exactly one paid order, so its current pending // payment — if there is one, and it isn't the payment just paid — is finalised as // cancelled/superseded instead of staying collectable. -func supersedeLivePayments(ctxWithTx context.Context, cartId int64, exceptPaymentId int64, paymentRepository PaymentRepository, transactionRepository TransactionRepository, availabilityReservation AvailabilityReservation, kdsNotificationRepository KdsNotificationRepository) *Error { +func supersedeLivePayments(ctxWithTx context.Context, cartId int64, exceptPaymentId int64, paymentRepository PaymentRepository, transactionRepository TransactionRepository, availabilityReservation AvailabilityReservation, kdsNotificationRepository KdsNotificationRepository, paymentVerificationRepository PaymentVerificationRepository) *Error { pending, err := paymentRepository.GetPendingPaymentByCartId(ctxWithTx, cartId) if err != nil { if err.Type == NotFound { @@ -491,7 +567,7 @@ func supersedeLivePayments(ctxWithTx context.Context, cartId int64, exceptPaymen ) reason := PaymentCancelReasonSuperseded - if _, finalizeErr := finalizeUncollectedPayment(ctxWithTx, pending, PaymentStateCancelled, &reason, paymentRepository, transactionRepository, availabilityReservation, kdsNotificationRepository); finalizeErr != nil { + if _, finalizeErr := finalizeUncollectedPayment(ctxWithTx, pending, PaymentStateCancelled, &reason, paymentRepository, transactionRepository, availabilityReservation, kdsNotificationRepository, paymentVerificationRepository); finalizeErr != nil { return finalizeErr } @@ -501,7 +577,7 @@ func supersedeLivePayments(ctxWithTx context.Context, cartId int64, exceptPaymen // expirePayment is FR-4's entry point for giving up on a payment on the clock alone (D7) — used // directly by ExpireStalePayments for cash, and by applyQrisStatus's expired branch for QRIS. func (usecase PaymentUsecase) expirePayment(ctxWithTx context.Context, payment Payment) (Payment, *Error) { - return finalizeUncollectedPayment(ctxWithTx, payment, PaymentStateExpired, nil, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository) + return finalizeUncollectedPayment(ctxWithTx, payment, PaymentStateExpired, nil, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository, usecase.paymentVerificationRepository) } // ExpireStalePayments claims up to paymentExpiryBatchSize pending payments past their expired_at, @@ -524,6 +600,23 @@ func (usecase PaymentUsecase) ExpireStalePayments(ctx context.Context) *Error { return nil } +// DeleteOrphanedVerificationPhotos is the D5 backstop, run on the same maintenance sweeper tick +// as ExpireStalePayments: every terminal path already deletes its own photo inside +// finalizeUncollectedPayment, so this should always delete zero rows. A non-zero count means some +// path broke the invariant, which is worth an operator's attention rather than a silent fix. +func (usecase PaymentUsecase) DeleteOrphanedVerificationPhotos(ctx context.Context) *Error { + deleted, err := usecase.paymentVerificationRepository.DeleteOrphaned(ctx, paymentVerificationOrphanBatchSize) + if err != nil { + return err + } + + if deleted > 0 { + slog.WarnContext(ctx, "deleted orphaned COD verification photos", slog.Int64("count", deleted)) + } + + return nil +} + func (usecase PaymentUsecase) expireOne(ctx context.Context, payment Payment, now time.Time) { logger := slog.With( slog.String("partnerReferenceNo", payment.PartnerReferenceNo), @@ -537,13 +630,14 @@ func (usecase PaymentUsecase) expireOne(ctx context.Context, payment Payment, no err := usecase.paymentRepository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { // D6: the batch read above is a stale snapshot by the time this per-payment transaction - // opens — a guest may have cancelled it, or another tick may already have expired it. - // Re-read under lock and stand down if it has already left pending. + // opens — a guest may have cancelled it, another tick may already have expired it, or a + // barista may have approved it (FR-5: approved is never expirable, however late a tick + // gets to it). Re-read under lock and stand down if it has left pending or expirability. locked, lockErr := usecase.paymentRepository.GetPaymentByPartnerReferenceNoForUpdate(ctxWithTx, payment.PartnerReferenceNo) if lockErr != nil { return lockErr } - if locked.Status != PaymentStatePending { + if locked.Status != PaymentStatePending || !locked.IsExpirable() { return nil } payment = locked @@ -622,6 +716,13 @@ func (usecase PaymentUsecase) CancelPayment(ctx context.Context, sessionId strin return usecase.loadPaymentTransaction(ctxWithTx, payment, &resultTransaction) } + // FR-5/D10: an approved COD order is being made — the guest may no longer cancel it + // themselves. Session ownership and pending-ness are already established above, so this + // is exactly CanBeCancelledBy's remaining condition. + if !payment.CanBeCancelledBy(sessionId, time.Now()) { + return &Error{Type: BadRequest, Message: "order cannot be cancelled once approved"} + } + if payment.RequiresGateway() { gatewayStatus, gatewayErr := usecase.paymentGatewayRepository.QueryQris(ctxWithTx, QueryQrisInput{ PartnerReferenceNo: payment.PartnerReferenceNo, @@ -656,7 +757,7 @@ func (usecase PaymentUsecase) CancelPayment(ctx context.Context, sessionId strin } reason := PaymentCancelReasonGuest - updatedPayment, finalizeErr := finalizeUncollectedPayment(ctxWithTx, payment, PaymentStateCancelled, &reason, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository) + updatedPayment, finalizeErr := finalizeUncollectedPayment(ctxWithTx, payment, PaymentStateCancelled, &reason, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository, usecase.paymentVerificationRepository) if finalizeErr != nil { return finalizeErr } @@ -828,8 +929,14 @@ func (usecase PaymentUsecase) refreshPendingPaymentStatus(ctxWithTx context.Cont // refreshPendingCashPaymentStatus is FR-3/D7: cash has no gateway to ask, so the server clock is // the whole truth. Past expired_at it shares expirePayment with applyQrisStatus's expired branch -// and the sweeper (FR-4) — one definition of what giving up on a payment does. +// and the sweeper (FR-4) — one definition of what giving up on a payment does. FR-5: an approved +// COD payment is left untouched, even past its own expired_at — the guest's own status poll must +// never expire an order the bar has started making. func (usecase PaymentUsecase) refreshPendingCashPaymentStatus(ctxWithTx context.Context, payment Payment, now time.Time) (Payment, ConfirmPaymentOutcome, *Error) { + if !payment.IsExpirable() { + return payment, ConfirmPaymentOutcomeIgnored, nil + } + if now.Before(payment.ExpiredAt) { payment.StatusCheckedAt = &now updated, err := usecase.paymentRepository.UpdatePaymentById(ctxWithTx, payment, payment.Id) diff --git a/apps/api/domain/payment_usecase_test.go b/apps/api/domain/payment_usecase_test.go index e4d80482..93dbd667 100644 --- a/apps/api/domain/payment_usecase_test.go +++ b/apps/api/domain/payment_usecase_test.go @@ -4,6 +4,7 @@ import ( "apps/api/data/mock" "apps/api/domain" "context" + "encoding/base64" "strings" "testing" "time" @@ -13,8 +14,16 @@ import ( "go.uber.org/mock/gomock" ) +// codPhotoBase64 is jpegMagicBytes (payment_verification_entity_test.go) padded past a trivial +// size and base64-encoded, no data-URL prefix, matching what the handler passes through. +func codPhotoBase64() string { + data := append(append([]byte{}, jpegMagicBytes...), []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0}...) + return base64.StdEncoding.EncodeToString(data) +} + const checkoutQrisExpirySeconds = 300 const checkoutCashExpirySeconds = 600 +const checkoutCodVerificationExpirySeconds = 900 const checkoutOrderPaymentWalletId = 9 func withPaymentTransactionMock(r *mock.MockPaymentRepository) { @@ -34,6 +43,7 @@ type paymentUsecaseMocks struct { kdsNotificationRepo *mock.MockKdsNotificationRepository kdsNotificationDispatcher *mock.MockKdsNotificationDispatcher whatsappNumberVerifier *mock.MockWhatsappNumberVerifier + paymentVerificationRepo *mock.MockPaymentVerificationRepository } func newPaymentUsecaseMocks(ctrl *gomock.Controller) paymentUsecaseMocks { @@ -46,6 +56,11 @@ func newPaymentUsecaseMocks(ctrl *gomock.Controller) paymentUsecaseMocks { whatsappNumberVerifier := mock.NewMockWhatsappNumberVerifier(ctrl) whatsappNumberVerifier.EXPECT().EnsureRegistered(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + // FR-6: finalizeUncollectedPayment's unconditional photo delete is a no-op for every test + // that isn't about the photo store itself; tests that care replace this expectation. + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentVerificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + return paymentUsecaseMocks{ paymentRepo: mock.NewMockPaymentRepository(ctrl), gatewayRepo: mock.NewMockPaymentGatewayRepository(ctrl), @@ -58,6 +73,7 @@ func newPaymentUsecaseMocks(ctrl *gomock.Controller) paymentUsecaseMocks { kdsNotificationRepo: mock.NewMockKdsNotificationRepository(ctrl), kdsNotificationDispatcher: kdsNotificationDispatcher, whatsappNumberVerifier: whatsappNumberVerifier, + paymentVerificationRepo: paymentVerificationRepo, } } @@ -78,15 +94,33 @@ func (m paymentUsecaseMocks) usecaseWithCancelEnabled() domain.PaymentUsecase { } func (m paymentUsecaseMocks) usecaseWithDispatcherAndCancelEnabled(dispatcher domain.KdsNotificationDispatcher, orderPaymentCancelEnabled bool) domain.PaymentUsecase { + return m.usecaseWithDispatcherCancelAndCodEnabled(dispatcher, orderPaymentCancelEnabled, false) +} + +// usecaseWithCodEnabled builds the usecase with ORDER_COD_PAYMENT_ENABLED on, for Checkout's cod +// branch tests (FR-2/D12). +func (m paymentUsecaseMocks) usecaseWithCodEnabled() domain.PaymentUsecase { + return m.usecaseWithDispatcherCancelAndCodEnabled(m.kdsNotificationDispatcher, false, true) +} + +func (m paymentUsecaseMocks) usecaseWithDispatcherCancelAndCodEnabled(dispatcher domain.KdsNotificationDispatcher, orderPaymentCancelEnabled bool, orderCodPaymentEnabled bool) domain.PaymentUsecase { availabilityReservation := domain.NewAvailabilityReservation(m.availabilityRepo) - return domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, dispatcher, m.whatsappNumberVerifier, checkoutQrisExpirySeconds, checkoutCashExpirySeconds, checkoutOrderPaymentWalletId, orderPaymentCancelEnabled) + return domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, dispatcher, m.whatsappNumberVerifier, m.paymentVerificationRepo, checkoutQrisExpirySeconds, checkoutCashExpirySeconds, checkoutCodVerificationExpirySeconds, checkoutOrderPaymentWalletId, orderPaymentCancelEnabled, orderCodPaymentEnabled) } // usecaseWithVerifier builds the usecase over a caller-supplied verifier instead of the // permissive default, for the tests that assert on EnsureRegistered's own call pattern. func (m paymentUsecaseMocks) usecaseWithVerifier(verifier domain.WhatsappNumberVerifier) domain.PaymentUsecase { availabilityReservation := domain.NewAvailabilityReservation(m.availabilityRepo) - return domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, m.kdsNotificationDispatcher, verifier, checkoutQrisExpirySeconds, checkoutCashExpirySeconds, checkoutOrderPaymentWalletId, false) + return domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, m.kdsNotificationDispatcher, verifier, m.paymentVerificationRepo, checkoutQrisExpirySeconds, checkoutCashExpirySeconds, checkoutCodVerificationExpirySeconds, checkoutOrderPaymentWalletId, false, false) +} + +// usecaseWithCancelEnabledAndPaymentVerificationRepo builds the usecase over a caller-supplied +// verification repository instead of the permissive default, for the tests that assert on +// DeleteByPaymentId's own call pattern (FR-6/D5). +func (m paymentUsecaseMocks) usecaseWithCancelEnabledAndPaymentVerificationRepo(paymentVerificationRepo domain.PaymentVerificationRepository) domain.PaymentUsecase { + availabilityReservation := domain.NewAvailabilityReservation(m.availabilityRepo) + return domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, m.kdsNotificationDispatcher, m.whatsappNumberVerifier, paymentVerificationRepo, checkoutQrisExpirySeconds, checkoutCashExpirySeconds, checkoutCodVerificationExpirySeconds, checkoutOrderPaymentWalletId, true, false) } func expectAvailableVariant(m paymentUsecaseMocks, variantId int64) { @@ -135,7 +169,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { m.walletRepo.EXPECT().GetWalletById(gomock.Any(), int64(checkoutOrderPaymentWalletId)). Return(domain.Wallet{}, &domain.Error{Type: domain.NotFound}) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.InternalServerError, err.Type) @@ -150,7 +184,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { m.walletRepo.EXPECT().GetWalletById(gomock.Any(), int64(checkoutOrderPaymentWalletId)). Return(domain.Wallet{Id: checkoutOrderPaymentWalletId, Name: "Cash", IsPaymentTarget: false}, nil) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.InternalServerError, err.Type) @@ -164,7 +198,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { withPaymentTransactionMock(m.paymentRepo) expectValidWallet(m) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", " ", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", " ", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -178,7 +212,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { // no transaction is ever opened for a bad number. m := newPaymentUsecaseMocks(ctrl) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "12345", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "12345", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -194,7 +228,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { verifier.EXPECT().EnsureRegistered(gomock.Any(), "6281234567890"). Return(&domain.Error{Type: domain.BadRequest, Reason: domain.ErrorReasonWhatsappNumberNotRegistered, Message: "customerWhatsappNumber is not registered on WhatsApp"}) - _, _, err := m.usecaseWithVerifier(verifier).Checkout(context.Background(), "session-1", "Budi", "0812-3456-7890", domain.PaymentMethodQris, "") + _, _, err := m.usecaseWithVerifier(verifier).Checkout(context.Background(), "session-1", "Budi", "0812-3456-7890", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -235,7 +269,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecaseWithVerifier(verifier).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecaseWithVerifier(verifier).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -277,7 +311,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecaseWithVerifier(verifier).Checkout(context.Background(), "session-1", "Budi Santoso", "0822-2222-2222", domain.PaymentMethodQris, "") + _, _, err := m.usecaseWithVerifier(verifier).Checkout(context.Background(), "session-1", "Budi Santoso", "0822-2222-2222", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -321,7 +355,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "0812-3456-7890", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "0812-3456-7890", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -359,7 +393,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -374,7 +408,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { expectNameUpsert(m, "session-1", "Budi") m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1").Return(domain.Cart{}, &domain.Error{Type: domain.NotFound}) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -392,7 +426,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1"). Return(domain.Cart{Id: 1, Status: domain.CartStatusActive, Items: []domain.CartItem{}}, nil) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -410,7 +444,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1"). Return(domain.Cart{Id: 1, Status: domain.CartStatusActive, Items: []domain.CartItem{{Id: 1, VariantId: 10, Amount: 1}}}, nil) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -427,7 +461,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { expectNameUpsert(m, "session-1", "Budi") m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1").Return(domain.Cart{}, &domain.Error{Type: domain.InternalServerError}) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.InternalServerError, err.Type) @@ -460,7 +494,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - payment, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, "") + payment, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) assert.Equal(t, existingPayment, payment) @@ -494,7 +528,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - payment, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodCash, "") + payment, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodCash, "", "") assert.Nil(t, err) assert.Equal(t, existingPayment, payment) @@ -537,7 +571,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - payment, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "0822-2222-2222", domain.PaymentMethodQris, "") + payment, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "0822-2222-2222", domain.PaymentMethodQris, "", "") assert.Nil(t, err) require.NotNil(t, payment.CustomerWhatsappNumber) @@ -586,7 +620,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - payment, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + payment, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) assert.Equal(t, "qr-content", payment.QrContent) @@ -651,7 +685,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -688,7 +722,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", " Budi ", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", " Budi ", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -721,7 +755,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { m.gatewayRepo.EXPECT().GenerateQris(gomock.Any(), gomock.Any()). Return(domain.QrisPayment{}, &domain.Error{Type: domain.InternalServerError, Message: "DOKU is unreachable"}) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadGateway, err.Type) @@ -765,7 +799,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -789,7 +823,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { Product: domain.Product{Name: "Kopi Susu", IsAvailable: true, AvailabilityTracking: domain.AvailabilityTrackingNone}, }, nil) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -839,7 +873,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return nil }) - payment, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCash, "") + payment, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCash, "", "") assert.Nil(t, err) assert.Equal(t, domain.PaymentMethodCash, payment.Method) @@ -878,11 +912,185 @@ func TestPaymentUsecase_Checkout(t *testing.T) { dispatcher := mock.NewMockKdsNotificationDispatcher(ctrl) dispatcher.EXPECT().TriggerDispatch().Times(1) - _, _, err := m.usecaseWithDispatcher(dispatcher).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCash, "") + _, _, err := m.usecaseWithDispatcher(dispatcher).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCash, "", "") + + assert.Nil(t, err) + }) + + t.Run("the cod flag off is a 400, and nothing is written", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCod, "", codPhotoBase64()) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + assert.Equal(t, "payment method is not available", err.Message) + }) + + t.Run("an invalid verification photo is a 400 before BeginTransaction, even with the flag on", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + + _, _, err := m.usecaseWithCodEnabled().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCod, "", base64.StdEncoding.EncodeToString([]byte("not a real photo"))) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("a non-base64 verification photo is a 400 before BeginTransaction", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + + _, _, err := m.usecaseWithCodEnabled().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCod, "", "not-base64-!!!") + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("a cod checkout mints an unpaid transaction, a pending/awaiting payment with the cod window, one photo row and one cod_verification kds row, and never calls the gateway", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + withPaymentTransactionMock(m.paymentRepo) + expectValidWallet(m) + expectNameUpsert(m, "session-1", "Budi") + + cart := cartWithOneItem(1, 5) + m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1").Return(cart, nil) + m.paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(1)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + m.variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(10)).Return(checkoutVariant(10, 15000), nil) + expectAvailableVariant(m, 10) + + beforeCheckout := time.Now() + + m.transactionRepo.EXPECT().CreateTransaction(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, transaction domain.Transaction) (domain.Transaction, *domain.Error) { + assert.Equal(t, domain.TransactionSourceOrder, transaction.Source) + transaction.Id = 200 + return transaction, nil + }) + + m.paymentRepo.EXPECT().CreatePayment(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, payment domain.Payment) (domain.Payment, *domain.Error) { + assert.Equal(t, domain.PaymentMethodCod, payment.Method) + assert.Equal(t, domain.PaymentStatePending, payment.Status) + require.NotNil(t, payment.VerificationStatus) + assert.Equal(t, domain.PaymentVerificationStatusAwaiting, *payment.VerificationStatus) + assert.Equal(t, "", payment.QrContent) + assert.Equal(t, "", payment.GatewayReferenceNo) + assert.WithinDuration(t, beforeCheckout.Add(checkoutCodVerificationExpirySeconds*time.Second), payment.ExpiredAt, time.Second) + payment.Id = 300 + return payment, nil + }) + + m.paymentVerificationRepo.EXPECT().Create(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, photo domain.PaymentVerificationPhoto) *domain.Error { + assert.Equal(t, int64(300), photo.PaymentId) + assert.Equal(t, "image/jpeg", photo.ContentType) + assert.NotEmpty(t, photo.Data) + return nil + }) + + m.gatewayRepo.EXPECT().GenerateQris(gomock.Any(), gomock.Any()).Times(0) + + m.kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), domain.KdsNotificationKindCodVerification). + DoAndReturn(func(_ context.Context, transaction domain.Transaction, _ domain.KdsNotificationKind) *domain.Error { + assert.Equal(t, int64(200), transaction.Id) + return nil + }) + + payment, transaction, err := m.usecaseWithCodEnabled().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCod, "", codPhotoBase64()) + + assert.Nil(t, err) + assert.Equal(t, domain.PaymentMethodCod, payment.Method) + assert.Equal(t, domain.PaymentStatePending, payment.Status) + assert.Equal(t, "", payment.QrContent) + assert.Nil(t, transaction.PaidAt) + }) + + t.Run("a cod checkout triggers a kds dispatch after its commit", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + withPaymentTransactionMock(m.paymentRepo) + expectValidWallet(m) + expectNameUpsert(m, "session-1", "Budi") + + cart := cartWithOneItem(1, 5) + m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1").Return(cart, nil) + m.paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(1)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + m.variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(10)).Return(checkoutVariant(10, 15000), nil) + expectAvailableVariant(m, 10) + + m.transactionRepo.EXPECT().CreateTransaction(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, transaction domain.Transaction) (domain.Transaction, *domain.Error) { + transaction.Id = 200 + return transaction, nil + }) + m.paymentRepo.EXPECT().CreatePayment(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, payment domain.Payment) (domain.Payment, *domain.Error) { + payment.Id = 300 + return payment, nil + }) + m.paymentVerificationRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + m.kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), domain.KdsNotificationKindCodVerification).Return(nil) + + dispatcher := mock.NewMockKdsNotificationDispatcher(ctrl) + dispatcher.EXPECT().TriggerDispatch().Times(1) + + _, _, err := m.usecaseWithDispatcherCancelAndCodEnabled(dispatcher, false, true).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCod, "", codPhotoBase64()) assert.Nil(t, err) }) + t.Run("an idempotent cod retry returns the existing payment and never replaces its stored photo", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + withPaymentTransactionMock(m.paymentRepo) + expectValidWallet(m) + expectNameUpsert(m, "session-1", "Budi") + + transactionId := int64(200) + awaiting := domain.PaymentVerificationStatusAwaiting + existingPayment := domain.Payment{ + Id: 300, CartId: 1, TransactionId: &transactionId, + Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, + VerificationStatus: &awaiting, ExpiredAt: time.Now().Add(10 * time.Minute), + } + + cart := cartWithOneItem(1, 5) + m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), "session-1").Return(cart, nil) + m.paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(1)).Return(existingPayment, nil) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), transactionId).Return(domain.Transaction{Id: transactionId, DiningOption: domain.DiningOptionDineIn}, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), existingPayment.Id). + DoAndReturn(func(_ context.Context, payment domain.Payment, id int64) (domain.Payment, *domain.Error) { + return payment, nil + }) + + // A second, different photo is submitted on the retry, but the idempotent branch never + // touches the photo repository — the barista verifies the photo the order was created with. + m.paymentVerificationRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Times(0) + m.kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + payment, _, err := m.usecaseWithCodEnabled().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodCod, "", codPhotoBase64()) + + assert.Nil(t, err) + assert.Equal(t, existingPayment.Id, payment.Id) + require.NotNil(t, payment.VerificationStatus) + assert.Equal(t, domain.PaymentVerificationStatusAwaiting, *payment.VerificationStatus) + }) + t.Run("a qris checkout never writes a kds notification at checkout, and never triggers a dispatch", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -919,7 +1127,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { dispatcher := mock.NewMockKdsNotificationDispatcher(ctrl) dispatcher.EXPECT().TriggerDispatch().Times(0) - _, _, err := m.usecaseWithDispatcher(dispatcher).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "") + _, _, err := m.usecaseWithDispatcher(dispatcher).Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) }) @@ -932,7 +1140,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { withPaymentTransactionMock(m.paymentRepo) expectValidWallet(m) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "delivery") + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, "delivery", "") assert.NotNil(t, err) assert.Equal(t, domain.BadRequest, err.Type) @@ -971,7 +1179,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { return payment, nil }) - _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, domain.DiningOptionTakeaway) + _, _, err := m.usecase().Checkout(context.Background(), "session-1", "Budi", "", domain.PaymentMethodQris, domain.DiningOptionTakeaway, "") assert.Nil(t, err) }) @@ -1006,7 +1214,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { }) m.transactionRepo.EXPECT().UpdateTransactionDiningOptionById(gomock.Any(), transactionId, domain.DiningOptionTakeaway).Return(nil) - _, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, domain.DiningOptionTakeaway) + _, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, domain.DiningOptionTakeaway, "") assert.Nil(t, err) assert.Equal(t, domain.DiningOptionTakeaway, transaction.DiningOption) @@ -1040,7 +1248,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { }) m.transactionRepo.EXPECT().UpdateTransactionDiningOptionById(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - _, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, domain.DiningOptionTakeaway) + _, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, domain.DiningOptionTakeaway, "") assert.Nil(t, err) assert.Equal(t, domain.DiningOptionTakeaway, transaction.DiningOption) @@ -1075,7 +1283,7 @@ func TestPaymentUsecase_Checkout(t *testing.T) { }) m.transactionRepo.EXPECT().UpdateTransactionDiningOptionById(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) - _, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, "") + _, transaction, err := m.usecase().Checkout(context.Background(), "session-1", "Budi Santoso", "", domain.PaymentMethodQris, "", "") assert.Nil(t, err) assert.Equal(t, domain.DiningOptionTakeaway, transaction.DiningOption) @@ -1667,6 +1875,52 @@ func TestPaymentUsecase_CancelPayment(t *testing.T) { assert.Equal(t, domain.NotFound, err.Type) }) + t.Run("FR-5/D10: an approved COD payment refuses the guest's own cancel, with no writes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + withPaymentTransactionMock(m.paymentRepo) + + payment := pendingPaymentFixture() + payment.Method = domain.PaymentMethodCod + approved := domain.PaymentVerificationStatusApproved + payment.VerificationStatus = &approved + m.paymentRepo.EXPECT().GetPaymentByPartnerReferenceNoForUpdate(gomock.Any(), payment.PartnerReferenceNo).Return(payment, nil) + // No UpdatePaymentById, gateway call, or transaction write past the guard. + + _, _, err := m.usecaseWithCancelEnabled().CancelPayment(context.Background(), payment.SessionId, payment.PartnerReferenceNo) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("FR-6/D5: cancelling an awaiting COD payment deletes its verification photo", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + withPaymentTransactionMock(m.paymentRepo) + + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + + payment := pendingPaymentFixture() + payment.Method = domain.PaymentMethodCod + awaiting := domain.PaymentVerificationStatusAwaiting + payment.VerificationStatus = &awaiting + m.paymentRepo.EXPECT().GetPaymentByPartnerReferenceNoForUpdate(gomock.Any(), payment.PartnerReferenceNo).Return(payment, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), payment.Id). + DoAndReturn(func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { return p, nil }) + paymentVerificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), payment.Id).Return(nil).Times(1) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil).Times(2) + m.transactionRepo.EXPECT().DeleteTransactionById(gomock.Any(), int64(99)).Return(nil) + + _, _, err := m.usecaseWithCancelEnabledAndPaymentVerificationRepo(paymentVerificationRepo). + CancelPayment(context.Background(), payment.SessionId, payment.PartnerReferenceNo) + + assert.Nil(t, err) + }) + t.Run("a pending cash payment is cancelled, its reservation released and its transaction soft-deleted", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -2397,6 +2651,32 @@ func TestPaymentUsecase_GetPaymentStatus(t *testing.T) { assert.Equal(t, domain.PaymentStateExpired, result.Status) }) + t.Run("FR-5: an approved COD payment past expired_at survives a guest's own status poll unchanged", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + withPaymentTransactionMock(m.paymentRepo) + + payment := pendingCashPaymentFixture() + payment.Method = domain.PaymentMethodCod + payment.ExpiredAt = time.Now().Add(-time.Minute) + approved := domain.PaymentVerificationStatusApproved + payment.VerificationStatus = &approved + + m.paymentRepo.EXPECT().GetPaymentByPartnerReferenceNo(gomock.Any(), payment.PartnerReferenceNo).Return(payment, nil) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + // No UpdatePaymentById, no availability release, no transaction delete: IsExpirable() is + // false, so refreshPendingCashPaymentStatus returns the payment exactly as read. + + result, _, err := m.usecase().GetPaymentStatus(context.Background(), payment.SessionId, payment.PartnerReferenceNo, "") + + assert.Nil(t, err) + assert.Equal(t, domain.PaymentStatePending, result.Status) + require.NotNil(t, result.VerificationStatus) + assert.Equal(t, domain.PaymentVerificationStatusApproved, *result.VerificationStatus) + }) + t.Run("an already-resolved payment is returned as-is without touching the gateway", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -2608,6 +2888,60 @@ func TestPaymentUsecase_ExpireStalePayments(t *testing.T) { assert.Nil(t, err) }) + t.Run("FR-5: a COD payment approved since the batch read is left alone, not expired", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + payment := pendingPaymentFixture() + payment.Method = domain.PaymentMethodCod + awaiting := domain.PaymentVerificationStatusAwaiting + payment.VerificationStatus = &awaiting + + m.paymentRepo.EXPECT().GetExpirablePayments(gomock.Any(), gomock.Any(), gomock.Any()).Return([]domain.Payment{payment}, nil) + withPaymentTransactionMock(m.paymentRepo) + // A barista approved it between the sweep's batch read and this per-payment transaction; + // the lock read sees that and IsExpirable() stands the sweeper down. + approved := domain.PaymentVerificationStatusApproved + locked := payment + locked.VerificationStatus = &approved + m.paymentRepo.EXPECT().GetPaymentByPartnerReferenceNoForUpdate(gomock.Any(), payment.PartnerReferenceNo).Return(locked, nil) + // No UpdatePaymentById, no transaction delete, no photo delete past this point. + + err := m.usecase().ExpireStalePayments(context.Background()) + + assert.Nil(t, err) + }) + + t.Run("FR-6/D5: an awaiting COD payment expires on the clock and loses its photo", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + + payment := pendingPaymentFixture() + payment.Method = domain.PaymentMethodCod + awaiting := domain.PaymentVerificationStatusAwaiting + payment.VerificationStatus = &awaiting + + m.paymentRepo.EXPECT().GetExpirablePayments(gomock.Any(), gomock.Any(), gomock.Any()).Return([]domain.Payment{payment}, nil) + withPaymentTransactionMock(m.paymentRepo) + m.paymentRepo.EXPECT().GetPaymentByPartnerReferenceNoForUpdate(gomock.Any(), payment.PartnerReferenceNo).Return(payment, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), payment.Id). + DoAndReturn(func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { + assert.Equal(t, domain.PaymentStateExpired, p.Status) + return p, nil + }) + paymentVerificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), payment.Id).Return(nil).Times(1) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + m.transactionRepo.EXPECT().DeleteTransactionById(gomock.Any(), int64(99)).Return(nil) + + err := m.usecaseWithCancelEnabledAndPaymentVerificationRepo(paymentVerificationRepo).ExpireStalePayments(context.Background()) + + assert.Nil(t, err) + }) + t.Run("a batch of a cash and a qris payment expires the cash one on the clock and confirms the qris one with the gateway first", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() @@ -2753,3 +3087,48 @@ func TestPaymentUsecase_ExpireStalePayments(t *testing.T) { assert.Nil(t, err) }) } + +// FR-6/D5: the backstop should always delete zero rows — every terminal path already deletes its +// own photo — so this only proves the wiring: the sweeper's bound is threaded through, and a +// non-zero count is not treated as an error (it is logged at warn as an invariant violation). +func TestPaymentUsecase_DeleteOrphanedVerificationPhotos(t *testing.T) { + t.Run("passes the sweeper's bound through and succeeds when nothing is orphaned", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentVerificationRepo.EXPECT().DeleteOrphaned(gomock.Any(), 50).Return(int64(0), nil) + + err := m.usecaseWithCancelEnabledAndPaymentVerificationRepo(paymentVerificationRepo).DeleteOrphanedVerificationPhotos(context.Background()) + + assert.Nil(t, err) + }) + + t.Run("a non-zero count is not an error", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentVerificationRepo.EXPECT().DeleteOrphaned(gomock.Any(), gomock.Any()).Return(int64(3), nil) + + err := m.usecaseWithCancelEnabledAndPaymentVerificationRepo(paymentVerificationRepo).DeleteOrphanedVerificationPhotos(context.Background()) + + assert.Nil(t, err) + }) + + t.Run("a repository failure is surfaced", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentUsecaseMocks(ctrl) + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentVerificationRepo.EXPECT().DeleteOrphaned(gomock.Any(), gomock.Any()). + Return(int64(0), &domain.Error{Type: domain.InternalServerError}) + + err := m.usecaseWithCancelEnabledAndPaymentVerificationRepo(paymentVerificationRepo).DeleteOrphanedVerificationPhotos(context.Background()) + + assert.NotNil(t, err) + }) +} diff --git a/apps/api/domain/payment_verification_entity.go b/apps/api/domain/payment_verification_entity.go new file mode 100644 index 00000000..7221e255 --- /dev/null +++ b/apps/api/domain/payment_verification_entity.go @@ -0,0 +1,45 @@ +package domain + +import ( + "net/http" + "time" +) + +// codVerificationPhotoMaxBytes bounds the decoded photo (D13): ~4x the expected size after the +// client's canvas downscale (FR-7), enough headroom without accepting an arbitrary upload. +const codVerificationPhotoMaxBytes = 1024 * 1024 + +var allowedVerificationPhotoContentTypes = map[string]bool{ + "image/jpeg": true, + "image/webp": true, +} + +// PaymentVerificationPhoto is the COD presence photo (D4): stored only for as long as its payment +// is cod + pending + awaiting (D5), hard-deleted in the same transaction as the barista's decision. +type PaymentVerificationPhoto struct { + PaymentId int64 + ContentType string + ByteSize int + Data []byte + CreatedAt time.Time +} + +// ValidateVerificationPhoto enforces FR-2's photo gate on decoded bytes: non-empty, within the +// size cap, and sniffed (never client-asserted) as JPEG or WebP. It returns the sniffed content +// type so the caller does not have to detect it a second time when building the stored row. +func ValidateVerificationPhoto(data []byte) (string, *Error) { + if len(data) == 0 { + return "", &Error{Type: BadRequest, Message: "verification photo is empty"} + } + + if len(data) > codVerificationPhotoMaxBytes { + return "", &Error{Type: BadRequest, Message: "verification photo is too large"} + } + + contentType := http.DetectContentType(data) + if !allowedVerificationPhotoContentTypes[contentType] { + return "", &Error{Type: BadRequest, Message: "verification photo must be a JPEG or WebP image"} + } + + return contentType, nil +} diff --git a/apps/api/domain/payment_verification_entity_test.go b/apps/api/domain/payment_verification_entity_test.go new file mode 100644 index 00000000..bfbeaf7d --- /dev/null +++ b/apps/api/domain/payment_verification_entity_test.go @@ -0,0 +1,65 @@ +package domain_test + +import ( + "apps/api/domain" + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var jpegMagicBytes = []byte{0xFF, 0xD8, 0xFF} +var pngMagicBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'} + +func webpBytes(size int) []byte { + data := bytes.Repeat([]byte{0}, size) + copy(data, []byte("RIFF")) + copy(data[8:], []byte("WEBPVP")) + return data +} + +func TestValidateVerificationPhoto_AcceptsJpegWithinTheSizeCap(t *testing.T) { + data := append(append([]byte{}, jpegMagicBytes...), bytes.Repeat([]byte{0}, 100)...) + + contentType, err := domain.ValidateVerificationPhoto(data) + + require.Nil(t, err) + assert.Equal(t, "image/jpeg", contentType) +} + +func TestValidateVerificationPhoto_AcceptsWebpWithinTheSizeCap(t *testing.T) { + data := webpBytes(100) + + contentType, err := domain.ValidateVerificationPhoto(data) + + require.Nil(t, err) + assert.Equal(t, "image/webp", contentType) +} + +func TestValidateVerificationPhoto_RejectsEmptyInput(t *testing.T) { + _, err := domain.ValidateVerificationPhoto([]byte{}) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) +} + +func TestValidateVerificationPhoto_RejectsOversizePhoto(t *testing.T) { + data := append(append([]byte{}, jpegMagicBytes...), bytes.Repeat([]byte{0}, 1024*1024)...) + + _, err := domain.ValidateVerificationPhoto(data) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) +} + +// A guest could rename a PNG to look like a JPEG, or a client bug could mislabel the content +// type — ValidateVerificationPhoto sniffs the actual bytes rather than trusting either. +func TestValidateVerificationPhoto_RejectsAPngDisguisedAsAJpeg(t *testing.T) { + data := append(append([]byte{}, pngMagicBytes...), bytes.Repeat([]byte{0}, 100)...) + + _, err := domain.ValidateVerificationPhoto(data) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) +} diff --git a/apps/api/domain/payment_verification_repository.go b/apps/api/domain/payment_verification_repository.go new file mode 100644 index 00000000..848bf4f9 --- /dev/null +++ b/apps/api/domain/payment_verification_repository.go @@ -0,0 +1,21 @@ +//go:generate mockgen -source=payment_verification_repository.go -destination=../data/mock/payment_verification_repository.go -package=mock + +package domain + +import "context" + +// PaymentVerificationRepository stores the COD presence-verification photo. D5's invariant — a +// row exists iff its payment is cod, pending and awaiting — is kept by every caller: Checkout +// creates it, and every exit from that state (approve, reject, cancel, expiry, supersede) deletes +// it in the same DB transaction as the decision. +type PaymentVerificationRepository interface { + Create(ctx context.Context, photo PaymentVerificationPhoto) *Error + GetByPaymentId(ctx context.Context, paymentId int64) (PaymentVerificationPhoto, *Error) + // DeleteByPaymentId is not an error when there is no matching row: every terminal path calls + // it unconditionally, including ones whose payment never had a photo (a non-COD method). + DeleteByPaymentId(ctx context.Context, paymentId int64) *Error + // DeleteOrphaned is the D5 backstop, run on the maintenance sweeper tick: photos whose payment + // is no longer cod + pending + awaiting. It should always delete zero rows; the caller logs a + // non-zero count at warn because it means some path broke the invariant. + DeleteOrphaned(ctx context.Context, limit int) (int64, *Error) +} diff --git a/apps/api/domain/payment_verification_usecase.go b/apps/api/domain/payment_verification_usecase.go new file mode 100644 index 00000000..ba2e8532 --- /dev/null +++ b/apps/api/domain/payment_verification_usecase.go @@ -0,0 +1,175 @@ +package domain + +import ( + "context" + "time" +) + +// PaymentVerificationUsecase is FR-4: the barista's decision on a COD order's presence photo. +// All three methods resolve the payment through the transaction id, the only identifier the POS +// holds (mirroring /pay, /unpay, /complete). +type PaymentVerificationUsecase struct { + paymentRepository PaymentRepository + paymentVerificationRepository PaymentVerificationRepository + transactionRepository TransactionRepository + cartRepository CartRepository + availabilityReservation AvailabilityReservation + kdsNotificationRepository KdsNotificationRepository + kdsNotificationDispatcher KdsNotificationDispatcher +} + +func NewPaymentVerificationUsecase( + paymentRepository PaymentRepository, + paymentVerificationRepository PaymentVerificationRepository, + transactionRepository TransactionRepository, + cartRepository CartRepository, + availabilityReservation AvailabilityReservation, + kdsNotificationRepository KdsNotificationRepository, + kdsNotificationDispatcher KdsNotificationDispatcher, +) PaymentVerificationUsecase { + return PaymentVerificationUsecase{ + paymentRepository: paymentRepository, + paymentVerificationRepository: paymentVerificationRepository, + transactionRepository: transactionRepository, + cartRepository: cartRepository, + availabilityReservation: availabilityReservation, + kdsNotificationRepository: kdsNotificationRepository, + kdsNotificationDispatcher: kdsNotificationDispatcher, + } +} + +// GetVerification is D14: a staff-only read of the presence photo. 404 when the transaction has +// no linked payment, the payment is not COD, or the photo is already gone — decided (approved or +// rejected) or never existed — never distinguishing which, so the barista's next move is always +// the same: refetch the list. +func (usecase PaymentVerificationUsecase) GetVerification(ctx context.Context, transactionId int64) (PaymentVerificationPhoto, *Error) { + payment, err := usecase.paymentRepository.GetPaymentByTransactionId(ctx, transactionId) + if err != nil { + if err.Type == NotFound { + return PaymentVerificationPhoto{}, &Error{Type: NotFound, Message: "verification photo not found"} + } + return PaymentVerificationPhoto{}, err + } + if payment.Method != PaymentMethodCod { + return PaymentVerificationPhoto{}, &Error{Type: NotFound, Message: "verification photo not found"} + } + + photo, photoErr := usecase.paymentVerificationRepository.GetByPaymentId(ctx, payment.Id) + if photoErr != nil { + if photoErr.Type == NotFound { + return PaymentVerificationPhoto{}, &Error{Type: NotFound, Message: "verification photo not found"} + } + return PaymentVerificationPhoto{}, photoErr + } + + return photo, nil +} + +// resolveAwaitingCodPayment is Approve's and Reject's shared guard: it locks the payment row with +// the same lock PayTransaction takes (GetPaymentByTransactionIdForUpdate), so a barista's decision, +// a guest cancel and the sweeper serialise on it instead of racing to a terminal state (Cancel D6), +// and names the state a 400 refuses in. +func (usecase PaymentVerificationUsecase) resolveAwaitingCodPayment(ctxWithTx context.Context, transactionId int64) (Payment, *Error) { + payment, err := usecase.paymentRepository.GetPaymentByTransactionIdForUpdate(ctxWithTx, transactionId) + if err != nil { + if err.Type == NotFound { + return Payment{}, &Error{Type: NotFound, Message: "order not found"} + } + return Payment{}, err + } + + if payment.Method != PaymentMethodCod { + return Payment{}, &Error{Type: BadRequest, Message: "order is not a cod order"} + } + + if payment.Status != PaymentStatePending { + switch payment.Status { + case PaymentStatePaid: + return Payment{}, &Error{Type: BadRequest, Message: "order already paid"} + case PaymentStateCancelled: + return Payment{}, &Error{Type: BadRequest, Message: "order was cancelled"} + default: + return Payment{}, &Error{Type: BadRequest, Message: "order is no longer awaiting verification"} + } + } + + // D19: past expired_at but not yet reached by the sweeper is still approvable — the lock above + // makes it a clean race, and the barista's decision beats a sweeper that is late by a tick. + if payment.VerificationStatus == nil || *payment.VerificationStatus != PaymentVerificationStatusAwaiting { + return Payment{}, &Error{Type: BadRequest, Message: "order already verified"} + } + + return payment, nil +} + +// Approve is FR-4: the bar may start making it. It converts the cart so the guest can start a new +// order (D16), enqueues the existing order_paid kind — COD's "start making" signal (D6) — and +// deletes the photo in the same transaction as the decision (D5). +func (usecase PaymentVerificationUsecase) Approve(ctx context.Context, transactionId int64) *Error { + err := usecase.paymentRepository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { + payment, resolveErr := usecase.resolveAwaitingCodPayment(ctxWithTx, transactionId) + if resolveErr != nil { + return resolveErr + } + + if payment.TransactionId == nil { + return &Error{Type: InternalServerError, Message: "payment has no transaction"} + } + + now := time.Now() + approved := PaymentVerificationStatusApproved + payment.VerificationStatus = &approved + payment.VerifiedAt = &now + + if _, updateErr := usecase.paymentRepository.UpdatePaymentById(ctxWithTx, payment, payment.Id); updateErr != nil { + return updateErr + } + + if deleteErr := usecase.paymentVerificationRepository.DeleteByPaymentId(ctxWithTx, payment.Id); deleteErr != nil { + return deleteErr + } + + transaction, txErr := usecase.transactionRepository.GetTransactionById(ctxWithTx, *payment.TransactionId) + if txErr != nil { + return txErr + } + + cart, cartErr := usecase.cartRepository.GetCartById(ctxWithTx, payment.CartId) + if cartErr != nil { + return cartErr + } + cart.Status = CartStatusConverted + if _, updateCartErr := usecase.cartRepository.UpdateCartById(ctxWithTx, cart, cart.Id); updateCartErr != nil { + return updateCartErr + } + + return usecase.kdsNotificationRepository.EnqueueForTransaction(ctxWithTx, transaction, KdsNotificationKindOrderPaid) + }) + + // D6: kicked after the commit so the barista's HTTP response never waits on Expo. + if err == nil { + usecase.kdsNotificationDispatcher.TriggerDispatch() + } + + return err +} + +// Reject is FR-4: the barista could not confirm the guest is in the café. +// finalizeUncollectedPayment already moves the payment to cancelled/rejected, releases +// availability, soft-deletes the transaction (D7) and deletes the photo (FR-6/D5) — every exit +// from "cod, pending, awaiting" shares that one function. +func (usecase PaymentVerificationUsecase) Reject(ctx context.Context, transactionId int64) *Error { + return usecase.paymentRepository.BeginTransaction(ctx, func(ctxWithTx context.Context) *Error { + payment, resolveErr := usecase.resolveAwaitingCodPayment(ctxWithTx, transactionId) + if resolveErr != nil { + return resolveErr + } + + now := time.Now() + payment.VerifiedAt = &now + + reason := PaymentCancelReasonRejected + _, finalizeErr := finalizeUncollectedPayment(ctxWithTx, payment, PaymentStateCancelled, &reason, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository, usecase.paymentVerificationRepository) + return finalizeErr + }) +} diff --git a/apps/api/domain/payment_verification_usecase_test.go b/apps/api/domain/payment_verification_usecase_test.go new file mode 100644 index 00000000..c07a158f --- /dev/null +++ b/apps/api/domain/payment_verification_usecase_test.go @@ -0,0 +1,402 @@ +package domain_test + +import ( + "apps/api/data/mock" + "apps/api/domain" + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +type paymentVerificationUsecaseMocks struct { + paymentRepo *mock.MockPaymentRepository + verificationRepo *mock.MockPaymentVerificationRepository + transactionRepo *mock.MockTransactionRepository + cartRepo *mock.MockCartRepository + availabilityRepo *mock.MockAvailabilityReservationRepository + kdsNotificationRepo *mock.MockKdsNotificationRepository + kdsNotificationDispatcher *mock.MockKdsNotificationDispatcher +} + +func newPaymentVerificationUsecaseMocks(ctrl *gomock.Controller) paymentVerificationUsecaseMocks { + return paymentVerificationUsecaseMocks{ + paymentRepo: mock.NewMockPaymentRepository(ctrl), + verificationRepo: mock.NewMockPaymentVerificationRepository(ctrl), + transactionRepo: mock.NewMockTransactionRepository(ctrl), + cartRepo: mock.NewMockCartRepository(ctrl), + availabilityRepo: mock.NewMockAvailabilityReservationRepository(ctrl), + kdsNotificationRepo: mock.NewMockKdsNotificationRepository(ctrl), + kdsNotificationDispatcher: mock.NewMockKdsNotificationDispatcher(ctrl), + } +} + +func (m paymentVerificationUsecaseMocks) usecase() domain.PaymentVerificationUsecase { + availabilityReservation := domain.NewAvailabilityReservation(m.availabilityRepo) + return domain.NewPaymentVerificationUsecase(m.paymentRepo, m.verificationRepo, m.transactionRepo, m.cartRepo, availabilityReservation, m.kdsNotificationRepo, m.kdsNotificationDispatcher) +} + +func withVerificationPaymentTransactionMock(r *mock.MockPaymentRepository) { + r.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) +} + +func awaitingCodPaymentFixture() domain.Payment { + transactionId := int64(99) + awaiting := domain.PaymentVerificationStatusAwaiting + return domain.Payment{ + Id: 42, + CartId: 7, + SessionId: "session-1", + TransactionId: &transactionId, + Method: domain.PaymentMethodCod, + Status: domain.PaymentStatePending, + VerificationStatus: &awaiting, + ExpiredAt: time.Now().Add(15 * time.Minute), + } +} + +func TestPaymentVerificationUsecase_GetVerification(t *testing.T) { + t.Run("returns the photo for a cod payment", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + payment := awaitingCodPaymentFixture() + m.paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(99)).Return(payment, nil) + + capturedAt := time.Now() + m.verificationRepo.EXPECT().GetByPaymentId(gomock.Any(), payment.Id). + Return(domain.PaymentVerificationPhoto{PaymentId: payment.Id, ContentType: "image/jpeg", Data: []byte("photo"), CreatedAt: capturedAt}, nil) + + photo, err := m.usecase().GetVerification(context.Background(), 99) + + assert.Nil(t, err) + assert.Equal(t, "image/jpeg", photo.ContentType) + }) + + t.Run("404 when there is no linked payment", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + m.paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(99)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + + _, err := m.usecase().GetVerification(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.NotFound, err.Type) + }) + + t.Run("404 when the payment is not cod", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + payment := awaitingCodPaymentFixture() + payment.Method = domain.PaymentMethodQris + m.paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(99)).Return(payment, nil) + + _, err := m.usecase().GetVerification(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.NotFound, err.Type) + }) + + t.Run("404 once the photo is already gone (decided)", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + payment := awaitingCodPaymentFixture() + m.paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(99)).Return(payment, nil) + m.verificationRepo.EXPECT().GetByPaymentId(gomock.Any(), payment.Id). + Return(domain.PaymentVerificationPhoto{}, &domain.Error{Type: domain.NotFound}) + + _, err := m.usecase().GetVerification(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.NotFound, err.Type) + }) +} + +func TestPaymentVerificationUsecase_Approve(t *testing.T) { + t.Run("approves, deletes the photo, converts the cart and enqueues order_paid", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + m.kdsNotificationDispatcher.EXPECT().TriggerDispatch().Times(1) + + payment := awaitingCodPaymentFixture() + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), payment.Id). + DoAndReturn(func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { + require.NotNil(t, p.VerificationStatus) + assert.Equal(t, domain.PaymentVerificationStatusApproved, *p.VerificationStatus) + require.NotNil(t, p.VerifiedAt) + return p, nil + }) + m.verificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), payment.Id).Return(nil) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + m.cartRepo.EXPECT().GetCartById(gomock.Any(), int64(7)).Return(domain.Cart{Id: 7, Status: domain.CartStatusActive}, nil) + m.cartRepo.EXPECT().UpdateCartById(gomock.Any(), gomock.Any(), int64(7)). + DoAndReturn(func(_ context.Context, c domain.Cart, id int64) (domain.Cart, *domain.Error) { + assert.Equal(t, domain.CartStatusConverted, c.Status) + return c, nil + }) + m.kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), domain.KdsNotificationKindOrderPaid). + DoAndReturn(func(_ context.Context, transaction domain.Transaction, kind domain.KdsNotificationKind) *domain.Error { + assert.Equal(t, int64(99), transaction.Id) + return nil + }) + + err := m.usecase().Approve(context.Background(), 99) + + assert.Nil(t, err) + }) + + t.Run("succeeds past expired_at while still pending (D19)", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + m.kdsNotificationDispatcher.EXPECT().TriggerDispatch().Times(1) + + payment := awaitingCodPaymentFixture() + payment.ExpiredAt = time.Now().Add(-1 * time.Minute) + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), payment.Id). + DoAndReturn(func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { return p, nil }) + m.verificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), payment.Id).Return(nil) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + m.cartRepo.EXPECT().GetCartById(gomock.Any(), int64(7)).Return(domain.Cart{Id: 7}, nil) + m.cartRepo.EXPECT().UpdateCartById(gomock.Any(), gomock.Any(), int64(7)). + DoAndReturn(func(_ context.Context, c domain.Cart, id int64) (domain.Cart, *domain.Error) { return c, nil }) + m.kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), domain.KdsNotificationKindOrderPaid).Return(nil) + + err := m.usecase().Approve(context.Background(), 99) + + assert.Nil(t, err) + }) + + t.Run("refuses a non-cod payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + payment.Method = domain.PaymentMethodQris + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Approve(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("refuses an already-approved payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + approved := domain.PaymentVerificationStatusApproved + payment.VerificationStatus = &approved + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Approve(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("refuses a cancelled payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + payment.Status = domain.PaymentStateCancelled + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Approve(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("refuses a paid payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + payment.Status = domain.PaymentStatePaid + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Approve(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("404 when there is no linked payment", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + + err := m.usecase().Approve(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.NotFound, err.Type) + }) +} + +func TestPaymentVerificationUsecase_Reject(t *testing.T) { + t.Run("rejects: payment cancelled/rejected, transaction soft-deleted, availability released, photo deleted", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), payment.Id). + DoAndReturn(func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { + assert.Equal(t, domain.PaymentStateCancelled, p.Status) + require.NotNil(t, p.CancelReason) + assert.Equal(t, domain.PaymentCancelReasonRejected, *p.CancelReason) + require.NotNil(t, p.VerifiedAt) + return p, nil + }) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + m.transactionRepo.EXPECT().DeleteTransactionById(gomock.Any(), int64(99)).Return(nil) + m.verificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), payment.Id).Return(nil) + + err := m.usecase().Reject(context.Background(), 99) + + assert.Nil(t, err) + }) + + t.Run("sends no kds notification (a barista already looked)", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + m.paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), payment.Id). + DoAndReturn(func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { return p, nil }) + m.transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + m.transactionRepo.EXPECT().DeleteTransactionById(gomock.Any(), int64(99)).Return(nil) + m.verificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), payment.Id).Return(nil) + m.kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), gomock.Any()).Times(0) + + err := m.usecase().Reject(context.Background(), 99) + + assert.Nil(t, err) + }) + + t.Run("refuses an already-approved payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + approved := domain.PaymentVerificationStatusApproved + payment.VerificationStatus = &approved + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Reject(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("refuses a non-cod payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + payment.Method = domain.PaymentMethodCash + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Reject(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("refuses a cancelled payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + payment.Status = domain.PaymentStateCancelled + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Reject(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("refuses a paid payment with 400", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + + payment := awaitingCodPaymentFixture() + payment.Status = domain.PaymentStatePaid + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + + err := m.usecase().Reject(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + }) + + t.Run("404 when there is no linked payment", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentVerificationUsecaseMocks(ctrl) + withVerificationPaymentTransactionMock(m.paymentRepo) + m.paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + + err := m.usecase().Reject(context.Background(), 99) + + require.NotNil(t, err) + assert.Equal(t, domain.NotFound, err.Type) + }) +} diff --git a/apps/api/domain/transaction_entity.go b/apps/api/domain/transaction_entity.go index 3db8eb0c..b5be52db 100644 --- a/apps/api/domain/transaction_entity.go +++ b/apps/api/domain/transaction_entity.go @@ -68,26 +68,27 @@ func (o DiningOption) IsValid() bool { } type Transaction struct { - Id int64 - CreatedAt time.Time - Name string - Source TransactionSource - DiningOption DiningOption - CartId *int64 - Cart *Cart - PagerNumber int64 - TransactionNumber int64 - WalletId *int64 - Wallet *Wallet - Total float32 - TotalIncome float32 - TransactionItems []TransactionItem - TransactionCoupons []TransactionCoupon - PaidAmount float32 - PaidAt *time.Time - CompletedAt *time.Time - DeletedAt *time.Time - PaymentMethod *PaymentMethod + Id int64 + CreatedAt time.Time + Name string + Source TransactionSource + DiningOption DiningOption + CartId *int64 + Cart *Cart + PagerNumber int64 + TransactionNumber int64 + WalletId *int64 + Wallet *Wallet + Total float32 + TotalIncome float32 + TransactionItems []TransactionItem + TransactionCoupons []TransactionCoupon + PaidAmount float32 + PaidAt *time.Time + CompletedAt *time.Time + DeletedAt *time.Time + PaymentMethod *PaymentMethod + PaymentVerificationStatus *PaymentVerificationStatus } type TransactionStatistic struct { diff --git a/apps/api/domain/transaction_usecase.go b/apps/api/domain/transaction_usecase.go index 1d96d222..83541841 100644 --- a/apps/api/domain/transaction_usecase.go +++ b/apps/api/domain/transaction_usecase.go @@ -7,32 +7,34 @@ import ( ) type TransactionUsecase struct { - transactionRepository TransactionRepository - variantRepository VariantRepository - couponRepository CouponRepository - walletRepository WalletRepository - availabilityReservation AvailabilityReservation - kdsNotificationRepository KdsNotificationRepository - kdsNotificationDispatcher KdsNotificationDispatcher - paymentRepository PaymentRepository - guestNotificationRepository GuestNotificationRepository - guestNotificationDispatcher GuestNotificationDispatcher - cartRepository CartRepository + transactionRepository TransactionRepository + variantRepository VariantRepository + couponRepository CouponRepository + walletRepository WalletRepository + availabilityReservation AvailabilityReservation + kdsNotificationRepository KdsNotificationRepository + kdsNotificationDispatcher KdsNotificationDispatcher + paymentRepository PaymentRepository + guestNotificationRepository GuestNotificationRepository + guestNotificationDispatcher GuestNotificationDispatcher + cartRepository CartRepository + paymentVerificationRepository PaymentVerificationRepository } -func NewTransactionUsecase(transactionRepository TransactionRepository, variantRepository VariantRepository, couponRepository CouponRepository, walletRepository WalletRepository, availabilityReservation AvailabilityReservation, kdsNotificationRepository KdsNotificationRepository, kdsNotificationDispatcher KdsNotificationDispatcher, paymentRepository PaymentRepository, guestNotificationRepository GuestNotificationRepository, guestNotificationDispatcher GuestNotificationDispatcher, cartRepository CartRepository) TransactionUsecase { +func NewTransactionUsecase(transactionRepository TransactionRepository, variantRepository VariantRepository, couponRepository CouponRepository, walletRepository WalletRepository, availabilityReservation AvailabilityReservation, kdsNotificationRepository KdsNotificationRepository, kdsNotificationDispatcher KdsNotificationDispatcher, paymentRepository PaymentRepository, guestNotificationRepository GuestNotificationRepository, guestNotificationDispatcher GuestNotificationDispatcher, cartRepository CartRepository, paymentVerificationRepository PaymentVerificationRepository) TransactionUsecase { return TransactionUsecase{ - transactionRepository: transactionRepository, - variantRepository: variantRepository, - couponRepository: couponRepository, - walletRepository: walletRepository, - availabilityReservation: availabilityReservation, - kdsNotificationRepository: kdsNotificationRepository, - kdsNotificationDispatcher: kdsNotificationDispatcher, - paymentRepository: paymentRepository, - guestNotificationRepository: guestNotificationRepository, - guestNotificationDispatcher: guestNotificationDispatcher, - cartRepository: cartRepository, + transactionRepository: transactionRepository, + variantRepository: variantRepository, + couponRepository: couponRepository, + walletRepository: walletRepository, + availabilityReservation: availabilityReservation, + kdsNotificationRepository: kdsNotificationRepository, + kdsNotificationDispatcher: kdsNotificationDispatcher, + paymentRepository: paymentRepository, + guestNotificationRepository: guestNotificationRepository, + guestNotificationDispatcher: guestNotificationDispatcher, + cartRepository: cartRepository, + paymentVerificationRepository: paymentVerificationRepository, } } @@ -241,6 +243,11 @@ func (usecase TransactionUsecase) PayTransaction(ctx context.Context, walletId i if hasLinkedPayment && linkedPayment.Status == PaymentStateCancelled { return &Error{Type: BadRequest, Message: "order was cancelled by the guest"} } + // FR-5/D9: nothing should be paid for before a barista has confirmed the guest is in the + // café — approval is what starts the bar making it. + if hasLinkedPayment && linkedPayment.IsAwaitingCodVerification() { + return &Error{Type: BadRequest, Message: "verify the order before taking payment"} + } // D23: the sweeper may have soft-deleted this order transaction between the cashier's // list fetch and their pressing Pay. Un-delete and re-reserve before paying it — the @@ -305,7 +312,7 @@ func (usecase TransactionUsecase) settleOrderPayment(ctx context.Context, paymen // D7/D8: the guest may have checked out again on this cart before the cashier settled this // (expired) order transaction by hand — finalise that newer attempt too, the same as a late // QRIS webhook does, so the cart never ends up with two paid orders. - return supersedeLivePayments(ctx, payment.CartId, payment.Id, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository) + return supersedeLivePayments(ctx, payment.CartId, payment.Id, usecase.paymentRepository, usecase.transactionRepository, usecase.availabilityReservation, usecase.kdsNotificationRepository, usecase.paymentVerificationRepository) } func payTransaction(ctx context.Context, transaction Transaction, transactionRepository TransactionRepository, walletRepository WalletRepository, kdsNotificationRepository KdsNotificationRepository, walletId int64, paidAmount float32) *Error { @@ -421,12 +428,9 @@ func (usecase TransactionUsecase) CompleteTransaction(ctx context.Context, id in return &Error{Type: BadRequest, Message: "transaction already completed"} } - if err := usecase.transactionRepository.CompleteTransaction(ctxWithTx, time.Now(), id); err != nil { - return err - } - // FR-2: no payment for the transaction is tolerated as a skipped notification, not a - // failed completion the barista already performed. + // failed completion the barista already performed. FR-5/D9: an awaiting COD payment + // refuses the completion outright — nothing should be made before a barista approves it. var sessionId *string var whatsappNumber *string payment, paymentErr := usecase.paymentRepository.GetPaymentByTransactionId(ctxWithTx, id) @@ -435,10 +439,17 @@ func (usecase TransactionUsecase) CompleteTransaction(ctx context.Context, id in return paymentErr } } else { + if payment.IsAwaitingCodVerification() { + return &Error{Type: BadRequest, Message: "verify the order before marking it ready"} + } sessionId = &payment.SessionId whatsappNumber = payment.CustomerWhatsappNumber } + if err := usecase.transactionRepository.CompleteTransaction(ctxWithTx, time.Now(), id); err != nil { + return err + } + return usecase.guestNotificationRepository.EnqueueForCompletedTransaction(ctxWithTx, transaction, sessionId, whatsappNumber) }) // FR-4/Phase 5: kicked after the commit so the barista's HTTP response never waits on a push service. diff --git a/apps/api/domain/transaction_usecase_test.go b/apps/api/domain/transaction_usecase_test.go index 395a8cdb..411d883b 100644 --- a/apps/api/domain/transaction_usecase_test.go +++ b/apps/api/domain/transaction_usecase_test.go @@ -52,6 +52,15 @@ func permissiveCartRepository(ctrl *gomock.Controller) *mock.MockCartRepository return mock.NewMockCartRepository(ctrl) } +// permissivePaymentVerificationRepository lets finalizeUncollectedPayment's unconditional +// DeleteByPaymentId (FR-6, reached via settleOrderPayment's supersedeLivePayments) fire freely for +// every test that isn't asserting on it directly. +func permissivePaymentVerificationRepository(ctrl *gomock.Controller) *mock.MockPaymentVerificationRepository { + repo := mock.NewMockPaymentVerificationRepository(ctrl) + repo.EXPECT().DeleteByPaymentId(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + return repo +} + func permissiveAvailabilityRepo(ctrl *gomock.Controller) *mock.MockAvailabilityReservationRepository { availabilityRepo := mock.NewMockAvailabilityReservationRepository(ctrl) availabilityRepo.EXPECT().LockVariantById(gomock.Any(), gomock.Any()).DoAndReturn( @@ -121,7 +130,7 @@ func TestTransactionUsecase_GetTransactionList(t *testing.T) { walletRepo := mock.NewMockWalletRepository(ctrl) tt.setupMock(txRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) transactions, total, err := usecase.GetTransactionList(context.Background(), "", domain.CreatedAt, domain.Ascending, 0, 10, domain.All, nil, nil, nil) if tt.expectedError != nil { @@ -149,7 +158,7 @@ func TestTransactionUsecase_GetTransactionList(t *testing.T) { Return([]domain.Transaction{{Id: 1, Source: domain.TransactionSourceOrder}}, nil) txRepo.EXPECT().GetTransactionListTotal(gomock.Any(), "", domain.All, nil, &orderSource, nil).Return(int64(1), nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) transactions, total, err := usecase.GetTransactionList(context.Background(), "", domain.CreatedAt, domain.Ascending, 0, 10, domain.All, nil, &orderSource, nil) assert.Nil(t, err) @@ -173,7 +182,7 @@ func TestTransactionUsecase_GetTransactionList(t *testing.T) { Return([]domain.Transaction{{Id: 1, Source: domain.TransactionSourceOrder}}, nil) txRepo.EXPECT().GetTransactionListTotal(gomock.Any(), "", domain.All, nil, nil, &fulfillment).Return(int64(1), nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) transactions, total, err := usecase.GetTransactionList(context.Background(), "", domain.CreatedAt, domain.Ascending, 0, 10, domain.All, nil, nil, &fulfillment) assert.Nil(t, err) @@ -360,7 +369,7 @@ func TestTransactionUsecase_CreateTransaction(t *testing.T) { availabilityRepo := mock.NewMockAvailabilityReservationRepository(ctrl) tt.setupMock(txRepo, variantRepo, couponRepo, availabilityRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) transaction, err := usecase.CreateTransaction(context.Background(), tt.input) if tt.expectedError != nil { @@ -410,7 +419,7 @@ func TestTransactionUsecase_CreateTransaction_DefaultsSourceToPos(t *testing.T) return tx, nil }) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) created, err := usecase.CreateTransaction(context.Background(), tt.input) assert.Nil(t, err) @@ -492,7 +501,7 @@ func TestTransactionUsecase_DeleteTransactionById(t *testing.T) { availabilityRepo := mock.NewMockAvailabilityReservationRepository(ctrl) tt.setupMock(txRepo, availabilityRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.DeleteTransactionById(context.Background(), tt.id) if tt.expectedError != nil { @@ -586,7 +595,7 @@ func TestTransactionUsecase_UnpayTransaction(t *testing.T) { walletRepo := mock.NewMockWalletRepository(ctrl) tt.setupMock(txRepo, walletRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.UnpayTransaction(context.Background(), tt.id) if tt.expectedError != nil { @@ -820,7 +829,7 @@ func TestTransactionUsecase_PayTransaction(t *testing.T) { kdsRepo := mock.NewMockKdsNotificationRepository(ctrl) tt.setupMock(txRepo, walletRepo, kdsRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), tt.walletId, tt.paidAmount, tt.id) if tt.expectedError != nil { @@ -857,7 +866,7 @@ func TestTransactionUsecase_PayTransaction_KdsDispatchTrigger(t *testing.T) { txRepo.EXPECT().PayTransaction(gomock.Any(), int64(1), gomock.Any(), float32(30000), int64(1)).Return(nil) dispatcher.EXPECT().TriggerDispatch().Times(1) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, dispatcher, permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, dispatcher, permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 1) assert.Nil(t, err) @@ -880,7 +889,7 @@ func TestTransactionUsecase_PayTransaction_KdsDispatchTrigger(t *testing.T) { txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(2)).Return(domain.Transaction{Id: 2, PaidAt: &now}, nil) dispatcher.EXPECT().TriggerDispatch().Times(0) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, dispatcher, permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, dispatcher, permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 2) assert.NotNil(t, err) @@ -933,7 +942,7 @@ func TestTransactionUsecase_PayTransaction_SettlesOrderPayment(t *testing.T) { // D7: no other pending payment on the same cart to supersede. paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(9)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 1) assert.Nil(t, err) @@ -967,7 +976,7 @@ func TestTransactionUsecase_PayTransaction_SettlesOrderPayment(t *testing.T) { Id: 6, Status: domain.PaymentStatePaid, }, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 2) assert.Nil(t, err) @@ -998,7 +1007,7 @@ func TestTransactionUsecase_PayTransaction_SettlesOrderPayment(t *testing.T) { paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(3)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 3) assert.Nil(t, err) @@ -1042,7 +1051,7 @@ func TestTransactionUsecase_PayTransaction_SettlesOrderPayment(t *testing.T) { // D7: no other pending payment on the same cart to supersede. paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(11)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 4) assert.Nil(t, err) @@ -1098,7 +1107,7 @@ func TestTransactionUsecase_PayTransaction_UndeletesSoftDeletedOrderTransaction( // D7: no other pending payment on the same cart to supersede. paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(9)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 1) assert.Nil(t, err) @@ -1130,7 +1139,7 @@ func TestTransactionUsecase_PayTransaction_UndeletesSoftDeletedOrderTransaction( paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(2)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 2) assert.Nil(t, err) @@ -1197,7 +1206,7 @@ func TestTransactionUsecase_PayTransaction_UndeletesSoftDeletedOrderTransaction( txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(201)).Return(domain.Transaction{Id: 201}, nil) txRepo.EXPECT().DeleteTransactionById(gomock.Any(), int64(201)).Return(nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 3) assert.Nil(t, err) @@ -1229,7 +1238,7 @@ func TestTransactionUsecase_PayTransaction_CancelledOrderGuard(t *testing.T) { }, nil) // No wallet, transaction income, KDS, or cart writes past the guard. - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 1) require.NotNil(t, err) @@ -1272,7 +1281,82 @@ func TestTransactionUsecase_PayTransaction_CancelledOrderGuard(t *testing.T) { cartRepo.EXPECT().UpdateCartById(gomock.Any(), gomock.Any(), int64(10)).Return(domain.Cart{}, nil) paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(10)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) + err := usecase.PayTransaction(context.Background(), 1, 30000, 2) + + assert.Nil(t, err) + }) +} + +// FR-5/D9: nothing should be paid for before a barista has confirmed the guest is in the café. +func TestTransactionUsecase_PayTransaction_CodAwaitingGuard(t *testing.T) { + t.Run("an awaiting COD order refuses payment with no wallet, income, or KDS writes", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + txRepo := mock.NewMockTransactionRepository(ctrl) + variantRepo := mock.NewMockVariantRepository(ctrl) + couponRepo := mock.NewMockCouponRepository(ctrl) + walletRepo := mock.NewMockWalletRepository(ctrl) + kdsRepo := mock.NewMockKdsNotificationRepository(ctrl) + paymentRepo := mock.NewMockPaymentRepository(ctrl) + cartRepo := mock.NewMockCartRepository(ctrl) + + awaiting := domain.PaymentVerificationStatusAwaiting + txRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(1)).Return(domain.Transaction{ + Id: 1, Source: domain.TransactionSourceOrder, Total: 30000, + }, nil) + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(1)).Return(domain.Payment{ + Id: 5, CartId: 9, Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &awaiting, + }, nil) + + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) + err := usecase.PayTransaction(context.Background(), 1, 30000, 1) + + require.NotNil(t, err) + assert.Equal(t, domain.BadRequest, err.Type) + assert.Equal(t, "verify the order before taking payment", err.Message) + }) + + t.Run("an approved COD order may be paid", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + txRepo := mock.NewMockTransactionRepository(ctrl) + variantRepo := mock.NewMockVariantRepository(ctrl) + couponRepo := mock.NewMockCouponRepository(ctrl) + walletRepo := mock.NewMockWalletRepository(ctrl) + kdsRepo := mock.NewMockKdsNotificationRepository(ctrl) + paymentRepo := mock.NewMockPaymentRepository(ctrl) + cartRepo := mock.NewMockCartRepository(ctrl) + + approved := domain.PaymentVerificationStatusApproved + txRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(2)).Return(domain.Transaction{ + Id: 2, Source: domain.TransactionSourceOrder, Total: 30000, + }, nil) + walletRepo.EXPECT().GetWalletById(gomock.Any(), int64(1)).Return(domain.Wallet{Id: 1, IsPaymentTarget: true}, nil) + walletRepo.EXPECT().UpdateWalletById(gomock.Any(), gomock.Any(), int64(1)).Return(domain.Wallet{}, nil) + txRepo.EXPECT().UpdateTransactionById(gomock.Any(), gomock.Any(), int64(2)).Return(domain.Transaction{}, nil) + kdsRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), domain.KdsNotificationKindOrderPaid).Return(nil) + txRepo.EXPECT().PayTransaction(gomock.Any(), int64(1), gomock.Any(), float32(30000), int64(2)).Return(nil) + + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(2)).Return(domain.Payment{ + Id: 6, CartId: 10, Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &approved, + }, nil) + paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), int64(6)).DoAndReturn( + func(_ context.Context, payment domain.Payment, id int64) (domain.Payment, *domain.Error) { + assert.Equal(t, domain.PaymentStatePaid, payment.Status) + return payment, nil + }) + cartRepo.EXPECT().GetCartById(gomock.Any(), int64(10)).Return(domain.Cart{Id: 10}, nil) + cartRepo.EXPECT().UpdateCartById(gomock.Any(), gomock.Any(), int64(10)).Return(domain.Cart{}, nil) + paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(10)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), kdsRepo, permissiveKdsNotificationDispatcher(ctrl), paymentRepo, permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), cartRepo, permissivePaymentVerificationRepository(ctrl)) err := usecase.PayTransaction(context.Background(), 1, 30000, 2) assert.Nil(t, err) @@ -1332,7 +1416,7 @@ func TestTransactionUsecase_CompleteTransaction(t *testing.T) { }, }, { - name: "payment lookup failure fails the completion", + name: "payment lookup failure fails the completion before anything is written", id: 7, setupMock: func(txRepo *mock.MockTransactionRepository, paymentRepo *mock.MockPaymentRepository, guestNotificationRepo *mock.MockGuestNotificationRepository) { txRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( @@ -1340,11 +1424,43 @@ func TestTransactionUsecase_CompleteTransaction(t *testing.T) { txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(7)).Return(domain.Transaction{ Id: 7, Source: domain.TransactionSourceOrder, CompletedAt: nil, }, nil) - txRepo.EXPECT().CompleteTransaction(gomock.Any(), gomock.Any(), int64(7)).Return(nil) paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(7)).Return(domain.Payment{}, &domain.Error{Type: domain.InternalServerError}) }, expectedError: &domain.Error{Type: domain.InternalServerError}, }, + { + name: "an awaiting COD payment refuses the completion before anything is written", + id: 8, + setupMock: func(txRepo *mock.MockTransactionRepository, paymentRepo *mock.MockPaymentRepository, guestNotificationRepo *mock.MockGuestNotificationRepository) { + awaiting := domain.PaymentVerificationStatusAwaiting + txRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(8)).Return(domain.Transaction{ + Id: 8, Source: domain.TransactionSourceOrder, CompletedAt: nil, + }, nil) + paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(8)).Return(domain.Payment{ + Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &awaiting, + }, nil) + }, + expectedError: &domain.Error{Type: domain.BadRequest}, + }, + { + name: "an approved COD payment may be marked ready", + id: 9, + setupMock: func(txRepo *mock.MockTransactionRepository, paymentRepo *mock.MockPaymentRepository, guestNotificationRepo *mock.MockGuestNotificationRepository) { + approved := domain.PaymentVerificationStatusApproved + txRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }) + txRepo.EXPECT().GetTransactionById(gomock.Any(), int64(9)).Return(domain.Transaction{ + Id: 9, Source: domain.TransactionSourceOrder, CompletedAt: nil, + }, nil) + txRepo.EXPECT().CompleteTransaction(gomock.Any(), gomock.Any(), int64(9)).Return(nil) + paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(9)).Return(domain.Payment{ + SessionId: "guest-session-9", Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &approved, + }, nil) + guestNotificationRepo.EXPECT().EnqueueForCompletedTransaction(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil) + }, + }, { name: "transaction not found", id: 2, @@ -1406,7 +1522,7 @@ func TestTransactionUsecase_CompleteTransaction(t *testing.T) { guestNotificationRepo := mock.NewMockGuestNotificationRepository(ctrl) tt.setupMock(txRepo, paymentRepo, guestNotificationRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), paymentRepo, guestNotificationRepo, permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), paymentRepo, guestNotificationRepo, permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.CompleteTransaction(context.Background(), tt.id) if tt.expectedError != nil { @@ -1442,7 +1558,7 @@ func TestTransactionUsecase_CompleteTransaction_GuestNotificationDispatchTrigger txRepo.EXPECT().CompleteTransaction(gomock.Any(), gomock.Any(), int64(1)).Return(nil) dispatcher.EXPECT().TriggerDispatch().Times(1) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), paymentRepo, guestNotificationRepo, dispatcher, permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), paymentRepo, guestNotificationRepo, dispatcher, permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.CompleteTransaction(context.Background(), 1) assert.Nil(t, err) @@ -1465,7 +1581,7 @@ func TestTransactionUsecase_CompleteTransaction_GuestNotificationDispatchTrigger }, nil) dispatcher.EXPECT().TriggerDispatch().Times(0) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), dispatcher, permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), dispatcher, permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.CompleteTransaction(context.Background(), 2) assert.NotNil(t, err) @@ -1559,7 +1675,7 @@ func TestTransactionUsecase_UncompleteTransaction(t *testing.T) { guestNotificationRepo := mock.NewMockGuestNotificationRepository(ctrl) tt.setupMock(txRepo, guestNotificationRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), guestNotificationRepo, permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), guestNotificationRepo, permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) err := usecase.UncompleteTransaction(context.Background(), tt.id) if tt.expectedError != nil { @@ -1638,7 +1754,7 @@ func TestTransactionUsecase_CompleteUncompleteComplete_LeavesExactlyOneGuestNoti return nil }) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), guestNotificationRepo, permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), guestNotificationRepo, permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) require.Nil(t, usecase.CompleteTransaction(context.Background(), 1)) @@ -1931,7 +2047,7 @@ func TestTransactionUsecase_UpdateTransactionById(t *testing.T) { availabilityRepo := mock.NewMockAvailabilityReservationRepository(ctrl) tt.setupMock(txRepo, variantRepo, couponRepo, availabilityRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), tt.input, tt.id) if tt.expectedError != nil { @@ -1962,7 +2078,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { }) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(50)).Return(domain.Coupon{Id: 50, Type: domain.Fixed, Amount: 15000}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 10, VariantId: 1, Amount: 1, DiscountAmount: 0, Note: "2 hour(s)"}, @@ -2002,7 +2118,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { }, }) couponRepo1.EXPECT().GetCouponById(gomock.Any(), int64(50)).Return(coupon, nil) - usecase1 := domain.NewTransactionUsecase(txRepo1, variantRepo1, couponRepo1, walletRepo1, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl1)), mock.NewMockKdsNotificationRepository(ctrl1), permissiveKdsNotificationDispatcher(ctrl1), permissivePaymentRepository(ctrl1), permissiveGuestNotificationRepository(ctrl1), permissiveGuestNotificationDispatcher(ctrl1), permissiveCartRepository(ctrl1)) + usecase1 := domain.NewTransactionUsecase(txRepo1, variantRepo1, couponRepo1, walletRepo1, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl1)), mock.NewMockKdsNotificationRepository(ctrl1), permissiveKdsNotificationDispatcher(ctrl1), permissivePaymentRepository(ctrl1), permissiveGuestNotificationRepository(ctrl1), permissiveGuestNotificationDispatcher(ctrl1), permissiveCartRepository(ctrl1), permissivePaymentVerificationRepository(ctrl1)) firstSave, err := usecase1.UpdateTransactionById(context.Background(), input, 1) assert.Nil(t, err) @@ -2018,7 +2134,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { }, }) couponRepo2.EXPECT().GetCouponById(gomock.Any(), int64(50)).Return(coupon, nil) - usecase2 := domain.NewTransactionUsecase(txRepo2, variantRepo2, couponRepo2, walletRepo2, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl2)), mock.NewMockKdsNotificationRepository(ctrl2), permissiveKdsNotificationDispatcher(ctrl2), permissivePaymentRepository(ctrl2), permissiveGuestNotificationRepository(ctrl2), permissiveGuestNotificationDispatcher(ctrl2), permissiveCartRepository(ctrl2)) + usecase2 := domain.NewTransactionUsecase(txRepo2, variantRepo2, couponRepo2, walletRepo2, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl2)), mock.NewMockKdsNotificationRepository(ctrl2), permissiveKdsNotificationDispatcher(ctrl2), permissivePaymentRepository(ctrl2), permissiveGuestNotificationRepository(ctrl2), permissiveGuestNotificationDispatcher(ctrl2), permissiveCartRepository(ctrl2), permissivePaymentVerificationRepository(ctrl2)) secondSave, err := usecase2.UpdateTransactionById(context.Background(), input, 1) assert.Nil(t, err) @@ -2039,7 +2155,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { }, }) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 13, VariantId: 1, Amount: 1, DiscountAmount: 0, Note: "2 hour(s)"}, @@ -2068,7 +2184,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { }) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(51)).Return(domain.Coupon{Id: 51, Type: domain.Fixed, Amount: 30000}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 12, VariantId: 1, Amount: 1, DiscountAmount: 0, Note: "1 hour(s)"}, @@ -2092,7 +2208,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(2)).Return(domain.Variant{Id: 2, Price: 30000}, nil) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(52)).Return(domain.Coupon{Id: 52, Type: domain.Percentage, Amount: 40}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(permissiveAvailabilityRepo(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(permissiveAvailabilityRepo(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 30, VariantId: 2, Amount: 1, DiscountAmount: 0}, @@ -2118,7 +2234,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(3)).Return(domain.Variant{Id: 3, Price: 25000}, nil) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(52)).Return(domain.Coupon{Id: 52, Type: domain.Percentage, Amount: 40}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(permissiveAvailabilityRepo(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(permissiveAvailabilityRepo(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 40, VariantId: 1, Amount: 1, DiscountAmount: 0}, @@ -2148,7 +2264,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Price: 20000}, nil) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(60)).Return(domain.Coupon{Id: 60, Type: domain.Fixed, Amount: 5000}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(permissiveAvailabilityRepo(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(permissiveAvailabilityRepo(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) updated, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 50, VariantId: 1, Amount: 1, DiscountAmount: 0}, @@ -2174,7 +2290,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(50)).Return(domain.Coupon{Id: 50, Type: domain.Fixed, Amount: 15000}, nil) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(60)).Return(domain.Coupon{Id: 60, Type: domain.Percentage, Amount: 40}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) _, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 60, VariantId: 1, Amount: 1, DiscountAmount: 0}, @@ -2197,7 +2313,7 @@ func TestTransactionUsecase_UpdateTransactionById_ItemCoupons(t *testing.T) { variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(1)).Return(domain.Variant{Id: 1, Price: 30000}, nil) couponRepo.EXPECT().GetCouponById(gomock.Any(), int64(50)).Return(domain.Coupon{Id: 50, Type: domain.Fixed, Amount: 15000}, nil) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) _, err := usecase.UpdateTransactionById(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 70, VariantId: 1, Amount: 1, DiscountAmount: 0}, @@ -2233,7 +2349,7 @@ func TestTransactionUsecase_CreateTransaction_ItemCoupon(t *testing.T) { return tx, nil }) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) created, err := usecase.CreateTransaction(context.Background(), domain.Transaction{ TransactionItems: []domain.TransactionItem{ {Id: 1, VariantId: 1, Amount: 1, DiscountAmount: 0}, @@ -2335,7 +2451,7 @@ func TestTransactionUsecase_GetTransactionStatistics(t *testing.T) { endDate = tt.endDate(t) } - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl)) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), mock.NewMockKdsNotificationRepository(ctrl), permissiveKdsNotificationDispatcher(ctrl), permissivePaymentRepository(ctrl), permissiveGuestNotificationRepository(ctrl), permissiveGuestNotificationDispatcher(ctrl), permissiveCartRepository(ctrl), permissivePaymentVerificationRepository(ctrl)) result, err := usecase.GetTransactionStatistics(context.Background(), tt.groupBy, startDate, endDate) if tt.expectedError != nil { diff --git a/apps/api/main.go b/apps/api/main.go index 0190d268..fd607bea 100644 --- a/apps/api/main.go +++ b/apps/api/main.go @@ -133,6 +133,7 @@ func main() { kdsDeviceRepository := mysql.NewKdsDeviceRepository(db) kdsNotificationRepository := mysql.NewKdsNotificationRepository(db) guestNotificationRepository := mysql.NewGuestNotificationRepository(db) + paymentVerificationRepository := mysql.NewPaymentVerificationRepository(db) orderPaymentWalletId, _ := strconv.ParseInt(env.OrderPaymentWalletId, 10, 64) @@ -141,7 +142,7 @@ func main() { availabilityReservation := domain.NewAvailabilityReservation(availabilityReservationRepository) walletUsecase := domain.NewWalletUsecase(walletRepository) - transactionUsecase := domain.NewTransactionUsecase(transactionRepository, variantRepository, couponRepository, walletRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase, paymentRepository, guestNotificationRepository, guestNotificationUsecase, cartRepository) + transactionUsecase := domain.NewTransactionUsecase(transactionRepository, variantRepository, couponRepository, walletRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase, paymentRepository, guestNotificationRepository, guestNotificationUsecase, cartRepository, paymentVerificationRepository) variantUsecase := domain.NewVariantUsecase(variantRepository, productRepository) productUsecase := domain.NewProductUsecase(productRepository, variantRepository) materialUsecase := domain.NewMaterialUsecase(materialRepository, supplierRepository) @@ -153,7 +154,8 @@ func main() { tableUsecase := domain.NewTableUsecase(tableRepository) cartUsecase := domain.NewCartUsecase(cartRepository, variantRepository, tableRepository, paymentRepository) customerUsecase := domain.NewCustomerUsecase(customerRepository) - paymentUsecase := domain.NewPaymentUsecase(paymentRepository, paymentGatewayRepository, customerRepository, cartRepository, transactionRepository, variantRepository, walletRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase, whatsappNumberVerifier, env.DokuQrisExpirySeconds, env.CashPaymentExpirySeconds, orderPaymentWalletId, env.OrderPaymentCancelEnabled) + paymentUsecase := domain.NewPaymentUsecase(paymentRepository, paymentGatewayRepository, customerRepository, cartRepository, transactionRepository, variantRepository, walletRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase, whatsappNumberVerifier, paymentVerificationRepository, env.DokuQrisExpirySeconds, env.CashPaymentExpirySeconds, env.CodVerificationExpirySeconds, orderPaymentWalletId, env.OrderPaymentCancelEnabled, env.OrderCodPaymentEnabled) + paymentVerificationUsecase := domain.NewPaymentVerificationUsecase(paymentRepository, paymentVerificationRepository, transactionRepository, cartRepository, availabilityReservation, kdsNotificationRepository, kdsNotificationUsecase) budgetUsecase := domain.NewBudgetUsecase(budgetRepository) authUsecase := domain.NewAuthUsecase(authRepository) calculationUsecase := domain.NewCalculationUsecase(calculationRepository, walletRepository) @@ -178,6 +180,7 @@ func main() { cartHandler := restapi.NewCartHandler(cartUsecase, env.OrderPaymentCancelEnabled) customerHandler := restapi.NewCustomerHandler(customerUsecase) paymentHandler := restapi.NewPaymentHandler(paymentUsecase) + paymentVerificationHandler := restapi.NewPaymentVerificationHandler(paymentVerificationUsecase) budgetHandler := restapi.NewBudgetHandler(budgetUsecase) authHandler := restapi.NewAuthHandler(authUsecase) calculationHandler := restapi.NewCalculationHandler(calculationUsecase) @@ -198,6 +201,7 @@ func main() { restapi.NewCartRouter(cartHandler).AddRouter(router) restapi.NewCustomerRouter(customerHandler).AddRouter(router) restapi.NewPaymentRouter(paymentHandler).AddRouter(router) + restapi.NewPaymentVerificationRouter(paymentVerificationHandler).AddRouter(router) restapi.NewExpenseRouter(expenseHandler).AddRouter(router) restapi.NewMaterialRouter(materialHandler).AddRouter(router) restapi.NewSupplierRouter(supplierHandler).AddRouter(router) @@ -278,6 +282,9 @@ func runMaintenanceSweeper(ctx context.Context, kdsNotificationUsecase domain.Kd if err := paymentUsecase.ExpireStalePayments(context.Background()); err != nil { logger.Error("payment expiry sweep failed", slog.Any("error", err)) } + if err := paymentUsecase.DeleteOrphanedVerificationPhotos(context.Background()); err != nil { + logger.Error("COD verification photo orphan sweep failed", slog.Any("error", err)) + } } } } diff --git a/apps/api/main_test.go b/apps/api/main_test.go index 93517dc4..72693235 100644 --- a/apps/api/main_test.go +++ b/apps/api/main_test.go @@ -39,11 +39,13 @@ func TestRunMaintenanceSweeper_CallsEveryJobPerTick(t *testing.T) { availabilityRepo := mock.NewMockAvailabilityReservationRepository(ctrl) kdsNotificationDispatcher := mock.NewMockKdsNotificationDispatcher(ctrl) availabilityReservation := domain.NewAvailabilityReservation(availabilityRepo) - paymentUsecase := domain.NewPaymentUsecase(paymentRepo, gatewayRepo, customerRepo, cartRepo, paymentTransactionRepo, variantRepo, walletRepo, availabilityReservation, kdsNotificationRepo, kdsNotificationDispatcher, domain.NoopWhatsappNumberVerifier{}, 300, 600, 1, false) + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentUsecase := domain.NewPaymentUsecase(paymentRepo, gatewayRepo, customerRepo, cartRepo, paymentTransactionRepo, variantRepo, walletRepo, availabilityReservation, kdsNotificationRepo, kdsNotificationDispatcher, domain.NoopWhatsappNumberVerifier{}, paymentVerificationRepo, 300, 600, 900, 1, false, false) kdsCalled := make(chan struct{}, 1) guestCalled := make(chan struct{}, 1) paymentCalled := make(chan struct{}, 1) + orphanPhotosCalled := make(chan struct{}, 1) kdsNotificationRepo.EXPECT().ClaimPendingKdsNotifications(gomock.Any(), gomock.Any()).DoAndReturn( func(context.Context, int) ([]domain.KdsNotification, *domain.Error) { kdsCalled <- struct{}{} @@ -60,6 +62,11 @@ func TestRunMaintenanceSweeper_CallsEveryJobPerTick(t *testing.T) { paymentCalled <- struct{}{} return nil, nil }).AnyTimes() + paymentVerificationRepo.EXPECT().DeleteOrphaned(gomock.Any(), gomock.Any()).DoAndReturn( + func(context.Context, int) (int64, *domain.Error) { + orphanPhotosCalled <- struct{}{} + return 0, nil + }).AnyTimes() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -70,7 +77,7 @@ func TestRunMaintenanceSweeper_CallsEveryJobPerTick(t *testing.T) { }() timeout := time.After(5 * time.Second) - for _, called := range []chan struct{}{kdsCalled, guestCalled, paymentCalled} { + for _, called := range []chan struct{}{kdsCalled, guestCalled, paymentCalled, orphanPhotosCalled} { select { case <-called: case <-timeout: diff --git a/apps/api/presentation/restapi/payment_handler.go b/apps/api/presentation/restapi/payment_handler.go index f5b78d4b..8e8c4ad7 100644 --- a/apps/api/presentation/restapi/payment_handler.go +++ b/apps/api/presentation/restapi/payment_handler.go @@ -37,6 +37,22 @@ func (handler PaymentHandler) Checkout(w http.ResponseWriter, r *http.Request) { return } + // FR-1: the use case must never see an inconsistent (method, verificationPhoto) pair. + hasVerificationPhoto := request.VerificationPhoto != nil && *request.VerificationPhoto != "" + if method == domain.PaymentMethodCod && !hasVerificationPhoto { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: "verificationPhoto is required for cod checkout"}) + return + } + if method != domain.PaymentMethodCod && hasVerificationPhoto { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: "verificationPhoto is only allowed for cod checkout"}) + return + } + + var verificationPhoto string + if request.VerificationPhoto != nil { + verificationPhoto = *request.VerificationPhoto + } + var customerWhatsappNumber string if request.CustomerWhatsappNumber != nil { customerWhatsappNumber = *request.CustomerWhatsappNumber @@ -47,7 +63,7 @@ func (handler PaymentHandler) Checkout(w http.ResponseWriter, r *http.Request) { diningOption = domain.DiningOption(*request.DiningOption) } - payment, transaction, usecaseErr := handler.usecase.Checkout(ctx, sessionId, request.CustomerName, customerWhatsappNumber, method, diningOption) + payment, transaction, usecaseErr := handler.usecase.Checkout(ctx, sessionId, request.CustomerName, customerWhatsappNumber, method, diningOption, verificationPhoto) if usecaseErr != nil { apiError := apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message, Reason: ToErrorReason(usecaseErr.Reason)} if usecaseErr.Type == domain.BadGateway { diff --git a/apps/api/presentation/restapi/payment_handler_test.go b/apps/api/presentation/restapi/payment_handler_test.go index 466b802f..07786a7a 100644 --- a/apps/api/presentation/restapi/payment_handler_test.go +++ b/apps/api/presentation/restapi/payment_handler_test.go @@ -6,6 +6,7 @@ import ( "apps/api/presentation/restapi" "bytes" "context" + "encoding/base64" "encoding/json" apiContract "libs/api-contract" "net/http" @@ -21,6 +22,10 @@ import ( const paymentHandlerOrderPaymentWalletId = 9 +// fakeJpegBytes sniffs as image/jpeg (http.DetectContentType only looks at the magic bytes) so +// ValidateVerificationPhoto accepts it without a real photo. +var fakeJpegBytes = []byte{0xFF, 0xD8, 0xFF, 0, 0, 0} + type paymentHandlerMocks struct { paymentRepo *mock.MockPaymentRepository gatewayRepo *mock.MockPaymentGatewayRepository @@ -32,6 +37,7 @@ type paymentHandlerMocks struct { availabilityRepo *mock.MockAvailabilityReservationRepository kdsNotificationRepo *mock.MockKdsNotificationRepository kdsNotificationDispatcher *mock.MockKdsNotificationDispatcher + paymentVerificationRepo *mock.MockPaymentVerificationRepository } func newPaymentHandlerMocks(ctrl *gomock.Controller) paymentHandlerMocks { @@ -40,6 +46,8 @@ func newPaymentHandlerMocks(ctrl *gomock.Controller) paymentHandlerMocks { kdsNotificationRepo.EXPECT().HasNotificationForTransaction(gomock.Any(), gomock.Any(), gomock.Any()).Return(true, nil).AnyTimes() kdsNotificationDispatcher := mock.NewMockKdsNotificationDispatcher(ctrl) kdsNotificationDispatcher.EXPECT().TriggerDispatch().AnyTimes() + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentVerificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() return paymentHandlerMocks{ paymentRepo: mock.NewMockPaymentRepository(ctrl), gatewayRepo: mock.NewMockPaymentGatewayRepository(ctrl), @@ -51,6 +59,7 @@ func newPaymentHandlerMocks(ctrl *gomock.Controller) paymentHandlerMocks { availabilityRepo: mock.NewMockAvailabilityReservationRepository(ctrl), kdsNotificationRepo: kdsNotificationRepo, kdsNotificationDispatcher: kdsNotificationDispatcher, + paymentVerificationRepo: paymentVerificationRepo, } } @@ -59,8 +68,18 @@ func (m paymentHandlerMocks) handler() restapi.PaymentHandler { } func (m paymentHandlerMocks) handlerWithCancelEnabled(orderPaymentCancelEnabled bool) restapi.PaymentHandler { + return m.handlerWithCancelAndCodEnabled(orderPaymentCancelEnabled, false) +} + +// handlerWithCodEnabled builds the handler with ORDER_COD_PAYMENT_ENABLED on, for Checkout's cod +// branch tests (FR-2/D12). +func (m paymentHandlerMocks) handlerWithCodEnabled() restapi.PaymentHandler { + return m.handlerWithCancelAndCodEnabled(false, true) +} + +func (m paymentHandlerMocks) handlerWithCancelAndCodEnabled(orderPaymentCancelEnabled bool, orderCodPaymentEnabled bool) restapi.PaymentHandler { availabilityReservation := domain.NewAvailabilityReservation(m.availabilityRepo) - usecase := domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, m.kdsNotificationDispatcher, domain.NoopWhatsappNumberVerifier{}, 300, 600, paymentHandlerOrderPaymentWalletId, orderPaymentCancelEnabled) + usecase := domain.NewPaymentUsecase(m.paymentRepo, m.gatewayRepo, m.customerRepo, m.cartRepo, m.transactionRepo, m.variantRepo, m.walletRepo, availabilityReservation, m.kdsNotificationRepo, m.kdsNotificationDispatcher, domain.NoopWhatsappNumberVerifier{}, m.paymentVerificationRepo, 300, 600, 900, paymentHandlerOrderPaymentWalletId, orderPaymentCancelEnabled, orderCodPaymentEnabled) return restapi.NewPaymentHandler(usecase) } @@ -253,6 +272,113 @@ func TestPaymentHandler_Checkout(t *testing.T) { assert.Equal(t, http.StatusBadRequest, w.Code) }) + t.Run("cod without a verificationPhoto is a 400, with no wallet or cart lookup", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentHandlerMocks(ctrl) + + req := httptest.NewRequest(http.MethodPost, "/carts/current/checkout", checkoutRequestBodyWithMethod("Budi", "cod")) + req.Header.Set("X-Session-Id", testSessionId) + w := httptest.NewRecorder() + m.handler().Checkout(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("qris with a verificationPhoto is a 400, with no wallet or cart lookup", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentHandlerMocks(ctrl) + + method := "qris" + photo := "ZmFrZS1qcGVn" + body, _ := json.Marshal(apiContract.PaymentCheckoutRequest{CustomerName: "Budi", Method: &method, VerificationPhoto: &photo}) + req := httptest.NewRequest(http.MethodPost, "/carts/current/checkout", bytes.NewBuffer(body)) + req.Header.Set("X-Session-Id", testSessionId) + w := httptest.NewRecorder() + m.handler().Checkout(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("cod with a verificationPhoto is a 400 while ORDER_COD_PAYMENT_ENABLED is off, with no wallet or cart lookup", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentHandlerMocks(ctrl) + + method := "cod" + photo := "ZmFrZS1qcGVn" + body, _ := json.Marshal(apiContract.PaymentCheckoutRequest{CustomerName: "Budi", Method: &method, VerificationPhoto: &photo}) + req := httptest.NewRequest(http.MethodPost, "/carts/current/checkout", bytes.NewBuffer(body)) + req.Header.Set("X-Session-Id", testSessionId) + w := httptest.NewRecorder() + m.handler().Checkout(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var apiErr apiContract.Error + assert.NoError(t, json.NewDecoder(bytes.NewBufferString(w.Body.String())).Decode(&apiErr)) + assert.Equal(t, "payment method is not available", apiErr.Message) + }) + + t.Run("a cod checkout succeeds without ever calling the gateway, once the flag is on", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentHandlerMocks(ctrl) + withPaymentHandlerTransactionMock(m.paymentRepo) + expectValidPaymentWallet(m) + + m.customerRepo.EXPECT().UpsertCustomerBySessionId(gomock.Any(), testSessionId, "Budi", nil). + Return(domain.Customer{Id: 1, SessionId: testSessionId, Name: "Budi"}, nil) + + tableId := int64(5) + m.cartRepo.EXPECT().GetActiveCartBySessionId(gomock.Any(), testSessionId).Return(domain.Cart{ + Id: 1, TableId: &tableId, Status: domain.CartStatusActive, + Items: []domain.CartItem{{Id: 1, VariantId: 10, Amount: 1}}, + }, nil) + m.paymentRepo.EXPECT().GetPendingPaymentByCartId(gomock.Any(), int64(1)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + m.variantRepo.EXPECT().GetVariantById(gomock.Any(), int64(10)).Return(domain.Variant{ + Id: 10, Price: 15000, Product: domain.Product{Name: "Kopi Susu"}, + }, nil) + expectAvailableHandlerVariant(m, 10) + + m.transactionRepo.EXPECT().CreateTransaction(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, transaction domain.Transaction) (domain.Transaction, *domain.Error) { + transaction.Id = 200 + transaction.Cart = &domain.Cart{Table: &domain.Table{Label: "Meja 1"}} + return transaction, nil + }) + m.paymentRepo.EXPECT().CreatePayment(gomock.Any(), gomock.Any()). + DoAndReturn(func(_ context.Context, payment domain.Payment) (domain.Payment, *domain.Error) { + payment.Id = 300 + return payment, nil + }) + m.paymentVerificationRepo.EXPECT().Create(gomock.Any(), gomock.Any()).Return(nil) + m.gatewayRepo.EXPECT().GenerateQris(gomock.Any(), gomock.Any()).Times(0) + + method := "cod" + photo := base64.StdEncoding.EncodeToString(fakeJpegBytes) + body, _ := json.Marshal(apiContract.PaymentCheckoutRequest{CustomerName: "Budi", Method: &method, VerificationPhoto: &photo}) + req := httptest.NewRequest(http.MethodPost, "/carts/current/checkout", bytes.NewBuffer(body)) + req.Header.Set("X-Session-Id", testSessionId) + w := httptest.NewRecorder() + m.handlerWithCodEnabled().Checkout(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp apiContract.PaymentResponse + assert.NoError(t, json.NewDecoder(bytes.NewBufferString(w.Body.String())).Decode(&resp)) + assert.Equal(t, "", resp.Data.QrContent) + assert.Equal(t, "pending", resp.Data.Status) + assert.Equal(t, "cod", resp.Data.Method) + require.NotNil(t, resp.Data.VerificationStatus) + assert.Equal(t, "awaiting", *resp.Data.VerificationStatus) + }) + t.Run("an invalid customerWhatsappNumber is a 400", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() diff --git a/apps/api/presentation/restapi/payment_route.go b/apps/api/presentation/restapi/payment_route.go index 63f77607..369f6347 100644 --- a/apps/api/presentation/restapi/payment_route.go +++ b/apps/api/presentation/restapi/payment_route.go @@ -14,8 +14,20 @@ func NewPaymentRouter(handler PaymentHandler) PaymentRouter { return PaymentRouter{handler: handler} } +// checkoutMaxRequestBytes is D13's 2 MiB cap on the checkout body — the first request in this +// codebase to accept anything like a photo. The base64 photo itself is bounded a second time, +// decoded, by ValidateVerificationPhoto; this is only the outer guard against an oversized body. +const checkoutMaxRequestBytes = 2 * 1024 * 1024 + +func limitCheckoutBody(next http.HandlerFunc) http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, checkoutMaxRequestBytes) + next.ServeHTTP(w, r) + }) +} + func (paymentRouter PaymentRouter) AddRouter(router *mux.Router) { - router.HandleFunc("/carts/current/checkout", RequireSessionId(paymentRouter.handler.Checkout)).Methods(http.MethodPost, http.MethodOptions) + router.HandleFunc("/carts/current/checkout", limitCheckoutBody(RequireSessionId(paymentRouter.handler.Checkout))).Methods(http.MethodPost, http.MethodOptions) router.HandleFunc("/payments", RequireSessionId(paymentRouter.handler.GetPaymentList)).Methods(http.MethodGet) router.HandleFunc("/payments/{partnerReferenceNo}", RequireSessionId(paymentRouter.handler.GetPaymentByPartnerReferenceNo)).Methods(http.MethodGet) router.HandleFunc("/payments/{partnerReferenceNo}/cancel", RequireSessionId(paymentRouter.handler.Cancel)).Methods(http.MethodPost, http.MethodOptions) diff --git a/apps/api/presentation/restapi/payment_route_test.go b/apps/api/presentation/restapi/payment_route_test.go new file mode 100644 index 00000000..18b6ad2c --- /dev/null +++ b/apps/api/presentation/restapi/payment_route_test.go @@ -0,0 +1,35 @@ +package restapi_test + +import ( + "apps/api/presentation/restapi" + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +// D13: the checkout route is the first in this codebase to accept anything like a photo, so it +// gets an outer 2 MiB MaxBytesReader guard on top of ValidateVerificationPhoto's own 1 MiB cap on +// the decoded bytes. +func TestPaymentRoute_ChecoutBodyOverTheCapIsRejected(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + m := newPaymentHandlerMocks(ctrl) + handler := m.handler() + + router := mux.NewRouter() + restapi.NewPaymentRouter(handler).AddRouter(router) + + oversizedBody := bytes.Repeat([]byte("a"), 3*1024*1024) + req := httptest.NewRequest(http.MethodPost, "/carts/current/checkout", bytes.NewReader(oversizedBody)) + req.Header.Set("X-Session-Id", testSessionId) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} diff --git a/apps/api/presentation/restapi/payment_transformer.go b/apps/api/presentation/restapi/payment_transformer.go index ca2d0e41..ad2b99be 100644 --- a/apps/api/presentation/restapi/payment_transformer.go +++ b/apps/api/presentation/restapi/payment_transformer.go @@ -80,6 +80,12 @@ func ToApiPayment(payment domain.Payment, transaction domain.Transaction, canCan cancelReason = &reason } + var verificationStatus *string + if payment.VerificationStatus != nil { + status := string(*payment.VerificationStatus) + verificationStatus = &status + } + return apiContract.Payment{ PartnerReferenceNo: payment.PartnerReferenceNo, Status: string(payment.Status), @@ -96,6 +102,7 @@ func ToApiPayment(payment domain.Payment, transaction domain.Transaction, canCan FulfillmentStatus: fulfillmentStatus, CanCancel: canCancel, CancelReason: cancelReason, + VerificationStatus: verificationStatus, } } @@ -105,6 +112,12 @@ func ToApiPaymentSummary(summary domain.PaymentSummary) apiContract.PaymentSumma fulfillmentStatus = "ready" } + var verificationStatus *string + if summary.VerificationStatus != nil { + status := string(*summary.VerificationStatus) + verificationStatus = &status + } + return apiContract.PaymentSummary{ PartnerReferenceNo: summary.PartnerReferenceNo, Status: string(summary.Status), @@ -118,5 +131,6 @@ func ToApiPaymentSummary(summary domain.PaymentSummary) apiContract.PaymentSumma CreatedAt: summary.CreatedAt, PaidAt: summary.PaidAt, DiningOption: apiContract.DiningOption(summary.DiningOption), + VerificationStatus: verificationStatus, } } diff --git a/apps/api/presentation/restapi/payment_transformer_test.go b/apps/api/presentation/restapi/payment_transformer_test.go index e2d80503..b8aee626 100644 --- a/apps/api/presentation/restapi/payment_transformer_test.go +++ b/apps/api/presentation/restapi/payment_transformer_test.go @@ -92,6 +92,52 @@ func TestToApiPayment_CancelReason(t *testing.T) { }) } +func TestToApiPayment_VerificationStatus(t *testing.T) { + transaction := domain.Transaction{TransactionNumber: 12} + + t.Run("nil for qris and cash", func(t *testing.T) { + for _, method := range []domain.PaymentMethod{domain.PaymentMethodQris, domain.PaymentMethodCash} { + payment := domain.Payment{PartnerReferenceNo: "ORD1", Status: domain.PaymentStatePending, Method: method} + + apiPayment := restapi.ToApiPayment(payment, transaction, false) + + assert.Nil(t, apiPayment.VerificationStatus) + } + }) + + t.Run("carries the status for a cod payment awaiting verification", func(t *testing.T) { + status := domain.PaymentVerificationStatusAwaiting + payment := domain.Payment{PartnerReferenceNo: "ORD1", Status: domain.PaymentStatePending, Method: domain.PaymentMethodCod, VerificationStatus: &status} + + apiPayment := restapi.ToApiPayment(payment, transaction, false) + + require.NotNil(t, apiPayment.VerificationStatus) + assert.Equal(t, "awaiting", *apiPayment.VerificationStatus) + }) +} + +func TestToApiPaymentSummary_VerificationStatus(t *testing.T) { + t.Run("nil for qris and cash", func(t *testing.T) { + for _, method := range []domain.PaymentMethod{domain.PaymentMethodQris, domain.PaymentMethodCash} { + summary := domain.PaymentSummary{PartnerReferenceNo: "ORD1", Status: domain.PaymentStatePending, Method: method} + + apiPaymentSummary := restapi.ToApiPaymentSummary(summary) + + assert.Nil(t, apiPaymentSummary.VerificationStatus) + } + }) + + t.Run("carries the status for a cod payment approved for pickup", func(t *testing.T) { + status := domain.PaymentVerificationStatusApproved + summary := domain.PaymentSummary{PartnerReferenceNo: "ORD1", Status: domain.PaymentStatePending, Method: domain.PaymentMethodCod, VerificationStatus: &status} + + apiPaymentSummary := restapi.ToApiPaymentSummary(summary) + + require.NotNil(t, apiPaymentSummary.VerificationStatus) + assert.Equal(t, "approved", *apiPaymentSummary.VerificationStatus) + }) +} + func TestToApiPaymentSummary_AgreesWithToApiPaymentOnFulfillmentStatus(t *testing.T) { tests := []struct { name string diff --git a/apps/api/presentation/restapi/payment_verification_handler.go b/apps/api/presentation/restapi/payment_verification_handler.go new file mode 100644 index 00000000..d2ef8c7c --- /dev/null +++ b/apps/api/presentation/restapi/payment_verification_handler.go @@ -0,0 +1,67 @@ +package restapi + +import ( + "apps/api/domain" + apiContract "libs/api-contract" + "net/http" +) + +type PaymentVerificationHandler struct { + usecase domain.PaymentVerificationUsecase +} + +func NewPaymentVerificationHandler(usecase domain.PaymentVerificationUsecase) PaymentVerificationHandler { + return PaymentVerificationHandler{usecase: usecase} +} + +func (handler PaymentVerificationHandler) GetVerification(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTransactionId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + photo, usecaseErr := handler.usecase.GetVerification(ctx, id) + if usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.TransactionVerificationResponse{Data: ToApiTransactionVerification(photo)}) +} + +func (handler PaymentVerificationHandler) Approve(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTransactionId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + if usecaseErr := handler.usecase.Approve(ctx, id); usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.SuccessResponse{Success: true}) +} + +func (handler PaymentVerificationHandler) Reject(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + id, err := GetTransactionId(r) + if err != nil { + WriteError(ctx, w, apiContract.Error{Code: apiContract.BAD_REQUEST, Message: err.Error()}) + return + } + + if usecaseErr := handler.usecase.Reject(ctx, id); usecaseErr != nil { + WriteError(ctx, w, apiContract.Error{Code: ToErrorCode(usecaseErr.Type), Message: usecaseErr.Message}) + return + } + + WriteResponse(w, apiContract.SuccessResponse{Success: true}) +} diff --git a/apps/api/presentation/restapi/payment_verification_handler_test.go b/apps/api/presentation/restapi/payment_verification_handler_test.go new file mode 100644 index 00000000..f566828f --- /dev/null +++ b/apps/api/presentation/restapi/payment_verification_handler_test.go @@ -0,0 +1,256 @@ +package restapi_test + +import ( + "apps/api/data/mock" + "apps/api/domain" + "apps/api/presentation/restapi" + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func newPaymentVerificationHandler(t *testing.T, setupMocks func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository)) (restapi.PaymentVerificationHandler, *gomock.Controller) { + ctrl := gomock.NewController(t) + paymentRepo := mock.NewMockPaymentRepository(ctrl) + verificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + transactionRepo := mock.NewMockTransactionRepository(ctrl) + cartRepo := mock.NewMockCartRepository(ctrl) + availabilityRepo := mock.NewMockAvailabilityReservationRepository(ctrl) + kdsNotificationRepo := mock.NewMockKdsNotificationRepository(ctrl) + kdsNotificationRepo.EXPECT().EnqueueForTransaction(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes() + kdsNotificationDispatcher := mock.NewMockKdsNotificationDispatcher(ctrl) + kdsNotificationDispatcher.EXPECT().TriggerDispatch().AnyTimes() + paymentRepo.EXPECT().BeginTransaction(gomock.Any(), gomock.Any()).DoAndReturn( + func(ctx context.Context, cb func(context.Context) *domain.Error) *domain.Error { return cb(ctx) }).AnyTimes() + + setupMocks(paymentRepo, verificationRepo, transactionRepo, cartRepo) + + usecase := domain.NewPaymentVerificationUsecase(paymentRepo, verificationRepo, transactionRepo, cartRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsNotificationRepo, kdsNotificationDispatcher) + return restapi.NewPaymentVerificationHandler(usecase), ctrl +} + +func TestPaymentVerificationHandler_GetVerification(t *testing.T) { + tests := []struct { + name string + transactionId string + setupMocks func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository) + expectedStatus int + }{ + { + name: "success", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository) { + paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(99)).Return(domain.Payment{Id: 1, Method: domain.PaymentMethodCod}, nil) + verificationRepo.EXPECT().GetByPaymentId(gomock.Any(), int64(1)).Return(domain.PaymentVerificationPhoto{ContentType: "image/jpeg", Data: []byte("photo"), CreatedAt: time.Now()}, nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid transaction id", + transactionId: "abc", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository) { + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "not found", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository) { + paymentRepo.EXPECT().GetPaymentByTransactionId(gomock.Any(), int64(99)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler, ctrl := newPaymentVerificationHandler(t, func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + tt.setupMocks(paymentRepo, verificationRepo) + }) + defer ctrl.Finish() + + req := httptest.NewRequest(http.MethodGet, "/transactions/"+tt.transactionId+"/verification", nil) + req = mux.SetURLVars(req, map[string]string{"transactionId": tt.transactionId}) + w := httptest.NewRecorder() + + handler.GetVerification(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestPaymentVerificationHandler_Approve(t *testing.T) { + tests := []struct { + name string + transactionId string + setupMocks func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) + expectedStatus int + }{ + { + name: "success", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + awaiting := domain.PaymentVerificationStatusAwaiting + txId := int64(99) + payment := domain.Payment{Id: 1, CartId: 7, TransactionId: &txId, Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &awaiting} + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), int64(1)).DoAndReturn( + func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { return p, nil }) + verificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), int64(1)).Return(nil) + transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + cartRepo.EXPECT().GetCartById(gomock.Any(), int64(7)).Return(domain.Cart{Id: 7}, nil) + cartRepo.EXPECT().UpdateCartById(gomock.Any(), gomock.Any(), int64(7)).DoAndReturn( + func(_ context.Context, c domain.Cart, id int64) (domain.Cart, *domain.Error) { return c, nil }) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid transaction id", + transactionId: "abc", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "not a cod order", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(domain.Payment{Method: domain.PaymentMethodQris, Status: domain.PaymentStatePending}, nil) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "no linked payment", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler, ctrl := newPaymentVerificationHandler(t, tt.setupMocks) + defer ctrl.Finish() + + req := httptest.NewRequest(http.MethodPut, "/transactions/"+tt.transactionId+"/verification/approve", nil) + req = mux.SetURLVars(req, map[string]string{"transactionId": tt.transactionId}) + w := httptest.NewRecorder() + + handler.Approve(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestPaymentVerificationHandler_Reject(t *testing.T) { + tests := []struct { + name string + transactionId string + setupMocks func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) + expectedStatus int + }{ + { + name: "success", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + awaiting := domain.PaymentVerificationStatusAwaiting + txId := int64(99) + payment := domain.Payment{Id: 1, TransactionId: &txId, Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &awaiting} + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(payment, nil) + paymentRepo.EXPECT().UpdatePaymentById(gomock.Any(), gomock.Any(), int64(1)).DoAndReturn( + func(_ context.Context, p domain.Payment, id int64) (domain.Payment, *domain.Error) { return p, nil }) + transactionRepo.EXPECT().GetTransactionById(gomock.Any(), int64(99)).Return(domain.Transaction{Id: 99}, nil) + transactionRepo.EXPECT().DeleteTransactionById(gomock.Any(), int64(99)).Return(nil) + verificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), int64(1)).Return(nil) + }, + expectedStatus: http.StatusOK, + }, + { + name: "invalid transaction id", + transactionId: "abc", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "already decided", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + approved := domain.PaymentVerificationStatusApproved + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(domain.Payment{Method: domain.PaymentMethodCod, Status: domain.PaymentStatePending, VerificationStatus: &approved}, nil) + }, + expectedStatus: http.StatusBadRequest, + }, + { + name: "no linked payment", + transactionId: "99", + setupMocks: func(paymentRepo *mock.MockPaymentRepository, verificationRepo *mock.MockPaymentVerificationRepository, transactionRepo *mock.MockTransactionRepository, cartRepo *mock.MockCartRepository) { + paymentRepo.EXPECT().GetPaymentByTransactionIdForUpdate(gomock.Any(), int64(99)).Return(domain.Payment{}, &domain.Error{Type: domain.NotFound}) + }, + expectedStatus: http.StatusNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + handler, ctrl := newPaymentVerificationHandler(t, tt.setupMocks) + defer ctrl.Finish() + + req := httptest.NewRequest(http.MethodPut, "/transactions/"+tt.transactionId+"/verification/reject", nil) + req = mux.SetURLVars(req, map[string]string{"transactionId": tt.transactionId}) + w := httptest.NewRecorder() + + handler.Reject(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestPaymentVerificationRoutes_RequireAuth(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + usecase := domain.NewPaymentVerificationUsecase( + mock.NewMockPaymentRepository(ctrl), + mock.NewMockPaymentVerificationRepository(ctrl), + mock.NewMockTransactionRepository(ctrl), + mock.NewMockCartRepository(ctrl), + domain.NewAvailabilityReservation(mock.NewMockAvailabilityReservationRepository(ctrl)), + mock.NewMockKdsNotificationRepository(ctrl), + mock.NewMockKdsNotificationDispatcher(ctrl), + ) + handler := restapi.NewPaymentVerificationHandler(usecase) + router := mux.NewRouter() + restapi.NewPaymentVerificationRouter(handler).AddRouter(router) + + tests := []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/transactions/1/verification"}, + {method: http.MethodPut, path: "/transactions/1/verification/approve"}, + {method: http.MethodPut, path: "/transactions/1/verification/reject"}, + } + + for _, tt := range tests { + t.Run(tt.method+" "+tt.path, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.path, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + } +} diff --git a/apps/api/presentation/restapi/payment_verification_route.go b/apps/api/presentation/restapi/payment_verification_route.go new file mode 100644 index 00000000..3dd6cd83 --- /dev/null +++ b/apps/api/presentation/restapi/payment_verification_route.go @@ -0,0 +1,21 @@ +package restapi + +import ( + "net/http" + + "github.com/gorilla/mux" +) + +type PaymentVerificationRouter struct { + handler PaymentVerificationHandler +} + +func NewPaymentVerificationRouter(handler PaymentVerificationHandler) PaymentVerificationRouter { + return PaymentVerificationRouter{handler: handler} +} + +func (paymentVerificationRouter PaymentVerificationRouter) AddRouter(router *mux.Router) { + router.HandleFunc("/transactions/{transactionId}/verification", CheckAuth(paymentVerificationRouter.handler.GetVerification)).Methods(http.MethodGet) + router.HandleFunc("/transactions/{transactionId}/verification/approve", CheckAuth(paymentVerificationRouter.handler.Approve)).Methods(http.MethodPut, http.MethodOptions) + router.HandleFunc("/transactions/{transactionId}/verification/reject", CheckAuth(paymentVerificationRouter.handler.Reject)).Methods(http.MethodPut, http.MethodOptions) +} diff --git a/apps/api/presentation/restapi/payment_verification_transformer.go b/apps/api/presentation/restapi/payment_verification_transformer.go new file mode 100644 index 00000000..6c547101 --- /dev/null +++ b/apps/api/presentation/restapi/payment_verification_transformer.go @@ -0,0 +1,16 @@ +package restapi + +import ( + "apps/api/domain" + "encoding/base64" + apiContract "libs/api-contract" +) + +// ToApiTransactionVerification is D14: the photo travels as a data URL, staff-only, never in a +// guest or list response. +func ToApiTransactionVerification(photo domain.PaymentVerificationPhoto) apiContract.TransactionVerification { + return apiContract.TransactionVerification{ + Photo: "data:" + photo.ContentType + ";base64," + base64.StdEncoding.EncodeToString(photo.Data), + CapturedAt: photo.CreatedAt, + } +} diff --git a/apps/api/presentation/restapi/transaction_handler_test.go b/apps/api/presentation/restapi/transaction_handler_test.go index 236c7708..0fece85b 100644 --- a/apps/api/presentation/restapi/transaction_handler_test.go +++ b/apps/api/presentation/restapi/transaction_handler_test.go @@ -48,8 +48,10 @@ func newTransactionHandler(t *testing.T, setupMocks func(txRepo *mock.MockTransa guestNotificationDispatcher := mock.NewMockGuestNotificationDispatcher(ctrl) guestNotificationDispatcher.EXPECT().TriggerDispatch().AnyTimes() cartRepo := mock.NewMockCartRepository(ctrl) + paymentVerificationRepo := mock.NewMockPaymentVerificationRepository(ctrl) + paymentVerificationRepo.EXPECT().DeleteByPaymentId(gomock.Any(), gomock.Any()).Return(nil).AnyTimes() setupMocks(txRepo, variantRepo, couponRepo, walletRepo) - usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsNotificationRepo, kdsNotificationDispatcher, paymentRepo, guestNotificationRepo, guestNotificationDispatcher, cartRepo) + usecase := domain.NewTransactionUsecase(txRepo, variantRepo, couponRepo, walletRepo, domain.NewAvailabilityReservation(availabilityRepo), kdsNotificationRepo, kdsNotificationDispatcher, paymentRepo, guestNotificationRepo, guestNotificationDispatcher, cartRepo, paymentVerificationRepo) return restapi.NewTransactionHandler(usecase), ctrl } diff --git a/apps/api/presentation/restapi/transaction_transformer.go b/apps/api/presentation/restapi/transaction_transformer.go index b22f78a4..ecdc6139 100644 --- a/apps/api/presentation/restapi/transaction_transformer.go +++ b/apps/api/presentation/restapi/transaction_transformer.go @@ -124,26 +124,33 @@ func ToApiTransaction(transaction domain.Transaction) apiContract.Transaction { paymentMethod = &m } + var paymentVerificationStatus *string + if transaction.PaymentVerificationStatus != nil { + s := string(*transaction.PaymentVerificationStatus) + paymentVerificationStatus = &s + } + return apiContract.Transaction{ - Id: transaction.Id, - Name: transaction.Name, - Source: string(transaction.Source), - DiningOption: apiContract.DiningOption(transaction.DiningOption), - Table: table, - PaymentMethod: paymentMethod, - PagerNumber: transaction.PagerNumber, - TransactionNumber: transaction.TransactionNumber, - DeletedAt: transaction.DeletedAt, - CreatedAt: transaction.CreatedAt, - WalletId: transaction.WalletId, - Wallet: (*apiContract.Wallet)(transaction.Wallet), - Total: transaction.Total, - TotalIncome: transaction.TotalIncome, - PaidAt: transaction.PaidAt, - PaidAmount: transaction.PaidAmount, - CompletedAt: transaction.CompletedAt, - TransactionItems: apiTransactionItems, - TransactionCoupons: apiTransactionCoupons, + Id: transaction.Id, + Name: transaction.Name, + Source: string(transaction.Source), + DiningOption: apiContract.DiningOption(transaction.DiningOption), + Table: table, + PaymentMethod: paymentMethod, + PaymentVerificationStatus: paymentVerificationStatus, + PagerNumber: transaction.PagerNumber, + TransactionNumber: transaction.TransactionNumber, + DeletedAt: transaction.DeletedAt, + CreatedAt: transaction.CreatedAt, + WalletId: transaction.WalletId, + Wallet: (*apiContract.Wallet)(transaction.Wallet), + Total: transaction.Total, + TotalIncome: transaction.TotalIncome, + PaidAt: transaction.PaidAt, + PaidAmount: transaction.PaidAmount, + CompletedAt: transaction.CompletedAt, + TransactionItems: apiTransactionItems, + TransactionCoupons: apiTransactionCoupons, } } diff --git a/apps/api/utils/env.go b/apps/api/utils/env.go index 50f0887b..8eba1849 100644 --- a/apps/api/utils/env.go +++ b/apps/api/utils/env.go @@ -25,20 +25,22 @@ type Env struct { ServiceName string CorsAllowedOrigins []string - DokuBaseURL string - DokuClientId string - DokuClientSecret string - DokuPrivateKey string - DokuMerchantId string - DokuChannelId string - DokuTerminalId string - DokuPostalCode string - DokuFeeType string - DokuQrisExpirySeconds int - CashPaymentExpirySeconds int - OrderPaymentWalletId string - OrderWebBaseURL string - OrderPaymentCancelEnabled bool + DokuBaseURL string + DokuClientId string + DokuClientSecret string + DokuPrivateKey string + DokuMerchantId string + DokuChannelId string + DokuTerminalId string + DokuPostalCode string + DokuFeeType string + DokuQrisExpirySeconds int + CashPaymentExpirySeconds int + CodVerificationExpirySeconds int + OrderPaymentWalletId string + OrderWebBaseURL string + OrderPaymentCancelEnabled bool + OrderCodPaymentEnabled bool ExpoPushAccessToken string KdsPushSound string @@ -79,20 +81,22 @@ func GetEnv() Env { ServiceName: serviceName, CorsAllowedOrigins: parseCorsAllowedOrigins(os.Getenv("CORS_ALLOWED_ORIGINS")), - DokuBaseURL: getCredential("DOKU_BASE_URL"), - DokuClientId: getCredential("DOKU_CLIENT_ID"), - DokuClientSecret: getCredential("DOKU_CLIENT_SECRET"), - DokuPrivateKey: os.Getenv("DOKU_PRIVATE_KEY"), - DokuMerchantId: getCredential("DOKU_MERCHANT_ID"), - DokuChannelId: getCredential("DOKU_CHANNEL_ID"), - DokuTerminalId: getCredential("DOKU_TERMINAL_ID"), - DokuPostalCode: getCredential("DOKU_MERCHANT_POSTAL_CODE"), - DokuFeeType: getCredential("DOKU_QRIS_FEE_TYPE"), - DokuQrisExpirySeconds: parseIntWithDefault(os.Getenv("DOKU_QRIS_EXPIRY_SECONDS"), 300), - CashPaymentExpirySeconds: parseIntWithDefault(os.Getenv("CASH_PAYMENT_EXPIRY_SECONDS"), 600), - OrderPaymentWalletId: os.Getenv("ORDER_PAYMENT_WALLET_ID"), - OrderWebBaseURL: os.Getenv("ORDER_WEB_BASE_URL"), - OrderPaymentCancelEnabled: parseBoolWithDefault(os.Getenv("ORDER_PAYMENT_CANCEL_ENABLED"), false), + DokuBaseURL: getCredential("DOKU_BASE_URL"), + DokuClientId: getCredential("DOKU_CLIENT_ID"), + DokuClientSecret: getCredential("DOKU_CLIENT_SECRET"), + DokuPrivateKey: os.Getenv("DOKU_PRIVATE_KEY"), + DokuMerchantId: getCredential("DOKU_MERCHANT_ID"), + DokuChannelId: getCredential("DOKU_CHANNEL_ID"), + DokuTerminalId: getCredential("DOKU_TERMINAL_ID"), + DokuPostalCode: getCredential("DOKU_MERCHANT_POSTAL_CODE"), + DokuFeeType: getCredential("DOKU_QRIS_FEE_TYPE"), + DokuQrisExpirySeconds: parseIntWithDefault(os.Getenv("DOKU_QRIS_EXPIRY_SECONDS"), 300), + CashPaymentExpirySeconds: parseIntWithDefault(os.Getenv("CASH_PAYMENT_EXPIRY_SECONDS"), 600), + CodVerificationExpirySeconds: parseIntWithDefault(os.Getenv("COD_VERIFICATION_EXPIRY_SECONDS"), 900), + OrderPaymentWalletId: os.Getenv("ORDER_PAYMENT_WALLET_ID"), + OrderWebBaseURL: os.Getenv("ORDER_WEB_BASE_URL"), + OrderPaymentCancelEnabled: parseBoolWithDefault(os.Getenv("ORDER_PAYMENT_CANCEL_ENABLED"), false), + OrderCodPaymentEnabled: parseBoolWithDefault(os.Getenv("ORDER_COD_PAYMENT_ENABLED"), false), ExpoPushAccessToken: getCredential("EXPO_PUSH_ACCESS_TOKEN"), KdsPushSound: stringWithDefault(os.Getenv("KDS_PUSH_SOUND"), "order_alert.wav"), diff --git a/apps/order-web-e2e/playwright.config.ts b/apps/order-web-e2e/playwright.config.ts index 39113f28..8cc60dff 100644 --- a/apps/order-web-e2e/playwright.config.ts +++ b/apps/order-web-e2e/playwright.config.ts @@ -29,12 +29,25 @@ export default defineConfig({ API_INTERNAL_BASE_URL: apiBaseURL, NEXT_PUBLIC_ORDER_CHECKOUT_ENABLED: 'true', NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED: 'true', + NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED: 'true', }, }, projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + use: { + ...devices['Desktop Chrome'], + // cod.spec.ts drives CameraCapture's real getUserMedia viewfinder + // (docs/prd-order-cod-payment.md FR-7/phase 14) against a fake + // device instead of real camera hardware; --use-fake-ui-for-media-stream + // auto-accepts the permission prompt so no dialog blocks the page. + launchOptions: { + args: [ + '--use-fake-ui-for-media-stream', + '--use-fake-device-for-media-stream', + ], + }, + }, }, ...(process.env['FULL_BROWSER_MATRIX'] diff --git a/apps/order-web-e2e/src/cod.spec.ts b/apps/order-web-e2e/src/cod.spec.ts new file mode 100644 index 00000000..56453872 --- /dev/null +++ b/apps/order-web-e2e/src/cod.spec.ts @@ -0,0 +1,127 @@ +import { test, expect } from '@playwright/test'; +import * as api from './utils/api'; +import * as sel from './utils/selectors'; +import { formatRupiah } from './utils/format'; + +const TS = Date.now(); +const CATEGORY_NAME = `E2E Cod Kategori ${TS}`; +const PRODUCT_NAME = `E2E Cod Kopi ${TS}`; +const TABLE_LABEL = `E2E Cod Meja ${TS}`; +const CUSTOMER_NAME = `E2E Cod Tamu ${TS}`; +const PRICE = 32000; + +test.describe.serial('Cash on Delivery (COD) Checkout', () => { + let category: api.Category; + let product: api.Product; + let variant: api.Variant; + let table: api.Table; + + test.beforeAll(async () => { + category = await api.createCategory({ name: CATEGORY_NAME, station: 'NONE' }); + product = await api.createProduct({ + categoryId: category.id, + name: PRODUCT_NAME, + imageUrl: '', + saleType: 'purchase', + status: 'published', + options: [{ name: 'Ukuran', values: [{ name: 'Reguler' }] }], + }); + variant = await api.createVariant({ + productId: product.id, + name: 'Reguler', + price: PRICE, + materials: [], + values: [{ optionValueId: product.options[0].values[0].id }], + }); + table = await api.createTable({ label: TABLE_LABEL }); + }); + + test.afterAll(async () => { + // The order transaction created below is left unpaid and awaiting + // pickup after approval — same as the other checkout specs, it is left + // behind in the throwaway per-run CI database rather than deleted here. + const cleanup = [ + () => api.deleteVariant(variant.id), + () => api.deleteProduct(product.id), + () => api.deleteCategory(category.id), + () => api.deleteTable(table.id), + ]; + for (const step of cleanup) { + await step().catch(() => { + // Ignore — the row may not exist if an earlier step failed. + }); + } + }); + + test('a guest builds a cart, checks out with COD and a camera photo, and the order walks awaiting → preparing → ready once the barista approves', async ({ + page, + }) => { + // getUserMedia against Chromium's fake device (playwright.config.ts) — + // granting the permission too means the flow works even if the + // --use-fake-ui-for-media-stream launch flag stops auto-accepting it. + await page.context().grantPermissions(['camera']); + + await page.goto(`t/${table.code}`); + await sel.menuList.productCard(page, PRODUCT_NAME).click(); + await sel.itemDetail.optionValueChip(page, 'Reguler').click(); + await sel.itemDetail.addToCartButton(page).click(); + await sel.cartBar.viewCartButton(page).click(); + await expect(sel.cartScreen.lineItemName(page, PRODUCT_NAME)).toBeVisible(); + + await sel.cartScreen.checkoutButton(page, formatRupiah(PRICE)).click(); + + await expect(sel.cartScreen.nameInput(page)).toBeVisible(); + await sel.cartScreen.fillCustomerDetails(page, CUSTOMER_NAME); + await sel.cartScreen.codMethodButton(page).click(); + + // Camera capture (FR-7): requesting → live → shutter → preview → accept. + await expect(sel.cartScreen.codPhotoMissingButton(page)).toBeDisabled(); + await expect(sel.cameraCapture.shutterButton(page)).toBeEnabled({ + timeout: 10_000, + }); + await sel.cameraCapture.shutterButton(page).click(); + await sel.cameraCapture.useThisPhotoButton(page).click(); + + const checkoutResponsePromise = page.waitForResponse( + (response) => + response.url().includes('/carts/current/checkout') && + response.request().method() === 'POST' + ); + await sel.cartScreen.submitCodButton(page).click(); + const checkoutResponse = await checkoutResponsePromise; + const { data: payment } = await checkoutResponse.json(); + expect(payment.method).toBe('cod'); + const partnerReferenceNo: string = payment.partnerReferenceNo; + expect(partnerReferenceNo).toBeTruthy(); + + await expect(page).toHaveURL(new RegExp(`/orders/${partnerReferenceNo}$`), { + timeout: 5_000, + }); + await expect(sel.orderStatus.codVerificationHeading(page)).toBeVisible(); + await expect( + sel.orderStatus.transactionNumberBadge(page, payment.transactionNumber) + ).toBeVisible(); + + // The barista's POS action, stood in for by a direct API call — the + // guest's own session cannot call this staff-only route (D14). + const [transaction] = await api.findTransactionsByQuery(CUSTOMER_NAME); + expect(transaction).toBeTruthy(); + await api.approveTransactionVerification(transaction.id); + + await expect(sel.orderStatus.preparingTitle(page)).toBeVisible({ + timeout: 10_000, + }); + await expect( + sel.orderStatus.payAtPickupBanner(page, formatRupiah(PRICE)) + ).toBeVisible(); + + await api.completeTransaction(transaction.id); + + await expect(sel.orderStatus.readyTitle(page)).toBeVisible({ + timeout: 20_000, + }); + await expect( + sel.orderStatus.payAtPickupBanner(page, formatRupiah(PRICE)) + ).toBeVisible(); + }); +}); diff --git a/apps/order-web-e2e/src/utils/api.ts b/apps/order-web-e2e/src/utils/api.ts index 86ee1709..8bc0b114 100644 --- a/apps/order-web-e2e/src/utils/api.ts +++ b/apps/order-web-e2e/src/utils/api.ts @@ -245,6 +245,29 @@ export async function payTransaction( await apiPut(`/transactions/${id}/pay`, { walletId, paidAmount }); } +// Staff-only COD verification (docs/prd-order-cod-payment.md FR-4) — the +// guest's own session can never call these; the spec stands in for the +// barista's POS action. +export async function approveTransactionVerification(id: number): Promise { + const context = await getContext(); + const response = await context.put(`/transactions/${id}/verification/approve`); + if (!response.ok()) { + throw new Error( + `PUT /transactions/${id}/verification/approve failed: ${response.status()} ${await response.text()}` + ); + } +} + +export async function rejectTransactionVerification(id: number): Promise { + const context = await getContext(); + const response = await context.put(`/transactions/${id}/verification/reject`); + if (!response.ok()) { + throw new Error( + `PUT /transactions/${id}/verification/reject failed: ${response.status()} ${await response.text()}` + ); + } +} + // Unlike payTransaction, this doesn't throw on a non-2xx response — for // asserting the D8 guard (cashier pays a guest-cancelled order) returns 400 // rather than merely failing. diff --git a/apps/order-web-e2e/src/utils/selectors.ts b/apps/order-web-e2e/src/utils/selectors.ts index 640ab24b..60237417 100644 --- a/apps/order-web-e2e/src/utils/selectors.ts +++ b/apps/order-web-e2e/src/utils/selectors.ts @@ -137,10 +137,28 @@ export const cartScreen = { submitCashButton: (page: Page) => page.getByRole('button', { name: 'Pesan & bayar di kasir' }), + // CustomerDetailsSheet.tsx's `cod` methodOptions entry — selected by its + // accessibilityLabel, since the visible label is shared with `cash`. + codMethodButton: (page: Page) => page.getByLabel('Bayar dengan COD'), + submitCodButton: (page: Page) => + page.getByRole('button', { name: 'Pesan dengan COD' }), + codPhotoMissingButton: (page: Page) => + page.getByRole('button', { name: 'Ambil foto dulu' }), + dineInButton: (page: Page) => page.getByLabel('Makan di sini'), takeawayButton: (page: Page) => page.getByLabel('Bawa pulang'), }; +// CameraCapture/index.tsx (FR-7) — driven with a fake `getUserMedia` device +// (see playwright.config.ts's chromium launchOptions), never a file input. +export const cameraCapture = { + shutterButton: (page: Page) => + page.getByRole('button', { name: 'Ambil foto' }), + useThisPhotoButton: (page: Page) => + page.getByRole('button', { name: 'Pakai foto ini' }), + retakeButton: (page: Page) => page.getByRole('button', { name: 'Ulangi' }), +}; + export const cartItemEdit = { saveButton: (page: Page) => page.getByRole('button', { name: 'Simpan' }), }; @@ -170,6 +188,16 @@ export const orderStatus = { waitingForCashPaymentText: (page: Page) => page.getByText('Menunggu pembayaran di kasir…'), + // CodVerificationView.tsx (FR-13) — the `awaitingVerification` variant. + codVerificationHeading: (page: Page) => + page.getByText('Menunggu konfirmasi barista…'), + // PayAtPickupBanner.tsx, shown on `preparing`/`ready` for an approved, + // still-unpaid COD order. + payAtPickupBanner: (page: Page, formattedAmount: string) => + page.getByText( + `Bayar ${formattedAmount} di kasir saat mengambil pesanan` + ), + expiredTitle: (page: Page) => page.getByText('Waktu pembayaran habis'), cashExpiredSubtitle: (page: Page) => page.getByText( diff --git a/apps/order-web/.env.example b/apps/order-web/.env.example index c1e3e0ee..39e28a9a 100644 --- a/apps/order-web/.env.example +++ b/apps/order-web/.env.example @@ -32,3 +32,9 @@ NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED= # and the cash instruction screen (docs/prd-order-cash-payment.md D15). # Falls back to "Lantai 1" when unset. NEXT_PUBLIC_ORDER_CASHIER_LOCATION=Lantai 1 + +# Trial feature flag for Cash on Delivery (docs/prd-order-cod-payment.md D12). +# Defaults to false when unset or anything other than "true" — the method +# picker shows COD only once this is "true" (and the API's +# ORDER_COD_PAYMENT_ENABLED agrees; the server gate is authoritative). +NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED= diff --git a/apps/pos-web-e2e/src/transactions.codVerification.spec.ts b/apps/pos-web-e2e/src/transactions.codVerification.spec.ts new file mode 100644 index 00000000..02b8e6f5 --- /dev/null +++ b/apps/pos-web-e2e/src/transactions.codVerification.spec.ts @@ -0,0 +1,201 @@ +import { test, expect } from '@playwright/test'; +import * as api from './utils/api'; +import * as sel from './utils/selectors'; + +const TS = Date.now(); +const CATEGORY_NAME = `E2E Cod Kategori ${TS}`; +const PRODUCT_NAME = `E2E Cod Product ${TS}`; +const TABLE_LABEL = `E2E Cod Meja ${TS}`; +const WALLET_NAME = `E2E Cod Wallet ${TS}`; +const CUSTOMER_NAME_APPROVE = `E2E Cod Approve Guest ${TS}`; +const CUSTOMER_NAME_REJECT = `E2E Cod Reject Guest ${TS}`; +const PRICE = 22_000; + +// A minimal valid JPEG (1x1 px) — real bytes, not a client-asserted content +// type, since ValidateVerificationPhoto sniffs them (docs/prd-order-cod-payment.md FR-2/D13). +const TINY_JPEG_BASE64 = + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='; + +test.describe.serial('POS COD Verification', () => { + let category: api.Category; + let product: api.Product; + let variant: api.Variant; + let table: api.Table; + let wallet: api.Wallet; + + test.beforeAll(async ({ request }) => { + category = await api.createCategory(request, { name: CATEGORY_NAME }); + product = await api.createProduct(request, { + categoryId: category.id, + name: PRODUCT_NAME, + imageUrl: 'https://placehold.co/400x400.jpg', + saleType: 'purchase', + options: [{ name: 'Size', values: [{ name: 'Standard' }] }], + }); + + const optionValueId = product.options[0]?.values[0]?.id; + if (!optionValueId) { + throw new Error('Product option value ID missing from API response'); + } + + variant = await api.createVariant(request, { + productId: product.id, + name: 'Standard', + price: PRICE, + materials: [], + values: [{ optionValueId }], + }); + + table = await api.createTable(request, { label: TABLE_LABEL }); + + wallet = await api.createWallet(request, { + name: WALLET_NAME, + balance: 0, + paymentCostPercentage: 0, + isCashless: true, + }); + }); + + test.afterAll(async ({ request }) => { + // The approved order below ends up paid, and DeleteTransactionById + // rejects paid transactions (same reasoning as transactions.fulfillment.spec.ts); + // the rejected one is soft-deleted already. Both are left behind in the + // throwaway per-run CI database rather than deleted here. + await api.deleteVariant(request, variant.id).catch(() => { + // Ignore — may already be gone + }); + await api.deleteProduct(request, product.id).catch(() => { + // Ignore — may already be gone + }); + await api.deleteCategory(request, category.id).catch(() => { + // Ignore — may already be gone + }); + await api.deleteTable(request, table.id).catch(() => { + // Ignore — may already be gone + }); + await api.deleteWallet(request, wallet.id).catch(() => { + // Ignore — may already be gone + }); + }); + + test('a barista verifies, approves, marks ready and pays a COD order', async ({ + page, + request, + }) => { + await api.checkoutOrderTransaction(request, { + tableCode: table.code, + variantId: variant.id, + amount: 1, + customerName: CUSTOMER_NAME_APPROVE, + method: 'cod', + verificationPhoto: TINY_JPEG_BASE64, + }); + + await page.goto('/transactions'); + await sel.transactionList.searchInput(page).fill(CUSTOMER_NAME_APPROVE); + await expect( + sel.transactionList.transactionItem(page, CUSTOMER_NAME_APPROVE) + ).toBeVisible({ timeout: 15_000 }); + await expect( + sel.transactionList.needsConfirmationBadge(page, CUSTOMER_NAME_APPROVE) + ).toBeVisible(); + + await sel.transactionList.menuButton(page, CUSTOMER_NAME_APPROVE).click(); + // Pay, Mark as Ready and Delete are hidden while awaiting confirmation (FR-12). + await expect( + sel.transactionList.menuOption(page, 'Pay') + ).toHaveCount(0); + await sel.transactionList.menuOption(page, 'Verify').click(); + + await expect(sel.transactionVerificationSheet.approveButton(page)).toBeVisible({ + timeout: 10_000, + }); + await expect(sel.transactionVerificationSheet.rejectButton(page)).toBeVisible(); + + await sel.transactionVerificationSheet.approveButton(page).click(); + + await expect( + sel.transactionList.needsConfirmationBadge(page, CUSTOMER_NAME_APPROVE) + ).toBeHidden({ timeout: 10_000 }); + await expect( + sel.transactionList.fulfillmentBadge(page, CUSTOMER_NAME_APPROVE, 'Preparing') + ).toBeVisible(); + await expect( + sel.transactionList.codUnpaidBadge(page, CUSTOMER_NAME_APPROVE) + ).toBeVisible(); + + await sel.transactionList.menuButton(page, CUSTOMER_NAME_APPROVE).click(); + await sel.transactionList.menuOption(page, 'Mark as Ready').click(); + await sel.common.confirmButton(page).click(); + + await expect( + sel.transactionList.fulfillmentBadge(page, CUSTOMER_NAME_APPROVE, 'Ready') + ).toBeVisible({ timeout: 10_000 }); + await expect( + sel.transactionList.codUnpaidBadge(page, CUSTOMER_NAME_APPROVE) + ).toBeVisible(); + + await sel.transactionList.menuButton(page, CUSTOMER_NAME_APPROVE).click(); + await sel.transactionList.menuOption(page, 'Pay').click(); + + await expect(page.getByText('Pay Transaction')).toBeVisible({ + timeout: 10_000, + }); + await sel.transactionPayDialog.walletSelect(page).click(); + await page + .locator('span[data-disable-theme]') + .filter({ hasText: WALLET_NAME }) + .click(); + await sel.transactionPayDialog.submitButton(page).click(); + + await expect(page.getByText('Pay Transaction')).toBeHidden({ + timeout: 15_000, + }); + + await page.goto('/transactions'); + await sel.transactionList.searchInput(page).fill(CUSTOMER_NAME_APPROVE); + await expect( + sel.transactionList.transactionItem(page, CUSTOMER_NAME_APPROVE) + ).toBeVisible({ timeout: 15_000 }); + await expect( + sel.transactionList.codUnpaidBadge(page, CUSTOMER_NAME_APPROVE) + ).toBeHidden(); + }); + + test('a barista verifies and rejects a COD order, and its row disappears', async ({ + page, + request, + }) => { + await api.checkoutOrderTransaction(request, { + tableCode: table.code, + variantId: variant.id, + amount: 1, + customerName: CUSTOMER_NAME_REJECT, + method: 'cod', + verificationPhoto: TINY_JPEG_BASE64, + }); + + await page.goto('/transactions'); + await sel.transactionList.searchInput(page).fill(CUSTOMER_NAME_REJECT); + await expect( + sel.transactionList.transactionItem(page, CUSTOMER_NAME_REJECT) + ).toBeVisible({ timeout: 15_000 }); + + await sel.transactionList.menuButton(page, CUSTOMER_NAME_REJECT).click(); + await sel.transactionList.menuOption(page, 'Verify').click(); + + await expect(sel.transactionVerificationSheet.rejectButton(page)).toBeVisible({ + timeout: 10_000, + }); + await sel.transactionVerificationSheet.rejectButton(page).click(); + + await expect(page.getByText(/^Reject order #\d+\?/)).toBeVisible(); + await sel.transactionVerificationSheet.rejectButton(page).click(); + + await page.goto('/transactions'); + await sel.transactionList.searchInput(page).fill(CUSTOMER_NAME_REJECT); + await expect( + sel.transactionList.transactionItem(page, CUSTOMER_NAME_REJECT) + ).toBeHidden({ timeout: 10_000 }); + }); +}); diff --git a/apps/pos-web-e2e/src/utils/api.ts b/apps/pos-web-e2e/src/utils/api.ts index 2e1b9719..2f4afb4f 100644 --- a/apps/pos-web-e2e/src/utils/api.ts +++ b/apps/pos-web-e2e/src/utils/api.ts @@ -510,6 +510,10 @@ export interface CheckoutOrderTransactionInput { variantId: number; amount: number; customerName: string; + // COD checkout (docs/prd-order-cod-payment.md FR-2) — both required + // together, exactly like the real order app's request. + method?: 'qris' | 'cash' | 'cod'; + verificationPhoto?: string; } // The staff-authenticated /api/transactions endpoint always defaults source @@ -543,7 +547,11 @@ export async function checkoutOrderTransaction( } const checkoutResponse = await request.post('/api/carts/current/checkout', { - data: { customerName: data.customerName }, + data: { + customerName: data.customerName, + method: data.method, + verificationPhoto: data.verificationPhoto, + }, headers, }); if (!checkoutResponse.ok()) { diff --git a/apps/pos-web-e2e/src/utils/selectors.ts b/apps/pos-web-e2e/src/utils/selectors.ts index 2803141b..ca3df68c 100644 --- a/apps/pos-web-e2e/src/utils/selectors.ts +++ b/apps/pos-web-e2e/src/utils/selectors.ts @@ -102,11 +102,48 @@ export const transactionList = { .last(), menuOption: ( page: Page, - label: 'Pay' | 'Unpay' | 'Mark as Ready' | 'Mark as Preparing' | 'Edit' | 'Delete' + label: + | 'Verify' + | 'Pay' + | 'Unpay' + | 'Mark as Ready' + | 'Mark as Preparing' + | 'Edit' + | 'Delete' ) => page.locator('[data-state="open"] li').filter({ hasText: label }).last(), filterButton: (page: Page) => page.getByRole('button', { name: 'Filter' }), fulfillmentFilterOption: (page: Page, label: 'Preparing' | 'Ready') => page.getByLabel(label, { exact: true }), + // TransactionListItem.tsx (docs/prd-order-cod-payment.md FR-12). + needsConfirmationBadge: (page: Page, name: string) => + page + .locator('h4') + .filter({ hasText: name }) + .locator('../../../..') + .getByText('Needs confirmation', { exact: true }), + codUnpaidBadge: (page: Page, name: string) => + page + .locator('h4') + .filter({ hasText: name }) + .locator('../../../..') + .getByText('COD · unpaid', { exact: true }), +}; + +// TransactionVerificationSheet.tsx (docs/prd-order-cod-payment.md FR-12) — +// unmountChildrenWhenHidden on the base Sheet keeps these unambiguous while +// the sheet is closed. +export const transactionVerificationSheet = { + photo: (page: Page) => page.getByRole('img'), + approveButton: (page: Page) => page.getByRole('button', { name: 'Approve' }), + rejectButton: (page: Page) => page.getByRole('button', { name: 'Reject' }), + closeButton: (page: Page) => page.getByRole('button', { name: 'Close' }), + confirmRejectText: (page: Page, transactionNumber: number) => + page.getByText( + `Reject order #${transactionNumber}? The guest's order will be cancelled.` + ), + cancelRejectButton: (page: Page) => page.getByRole('button', { name: 'Cancel' }), + goneText: (page: Page) => + page.getByText('Photo no longer available'), }; export const transactionDetail = { diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 941fe744..7d60feac 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -67,7 +67,7 @@ export default defineConfig({ { text: 'Coupons', link: '/sales/coupons' }, { text: 'Board-game Rentals', link: '/sales/rentals' }, { text: 'Table Ordering', link: '/sales/table-ordering' }, - { text: 'Order Checkout (QRIS & Cash)', link: '/sales/order-checkout' }, + { text: 'Order Checkout (QRIS, Cash & COD)', link: '/sales/order-checkout' }, { text: 'Order History', link: '/sales/order-history' }, { text: 'Kitchen Display System (KDS)', link: '/sales/kds' }, { text: 'Guest WhatsApp Notifications', link: '/sales/order-notifications' }, diff --git a/docs-site/sales/kds.md b/docs-site/sales/kds.md index a36e274c..5bd5d1ba 100644 --- a/docs-site/sales/kds.md +++ b/docs-site/sales/kds.md @@ -40,6 +40,14 @@ tagging that already splits the printed order slip into **Bar** and **Kitchen** nothing but a board-game ticket still needs someone at the till to collect it — so it fires regardless of what's in the cart. The same transaction buzzes again, as a normal `New order` alert, once the cashier actually takes the payment. +- **A guest checking out with [COD](/sales/order-checkout#cash-on-delivery-cod) buzzes every phone + with a distinct "verify this" alert the instant the order is placed** — not once it's paid, and + not the normal `New order` alert, since nothing should be made until a barista has confirmed the + guest from the photo (see [Verifying a COD Order](/sales/transactions#verifying-a-cod-order)). + The station rule and the business-day staleness check are both bypassed, the same as the cash + alert above, because this is a request for a human decision, not a preparation signal. The + ordinary `New order` alert follows once the barista approves it — with an extra line naming what + to collect, since the money still hasn't moved. ### Reading the alert @@ -59,12 +67,30 @@ Cash order #12 — Table 4 Collect Rp 45.000 at the counter · BAR: 2× Kopi Susu Gula Aren, 1× Americano ``` +A guest checking out with COD produces a different alert still, aimed at the barista rather than +the bar — there's nothing to make yet, only a photo to look at: + +``` +Verify COD order #12 — Meja 4 +Check the photo in the POS · Rp 45.000 +``` + +Once the barista approves it, the normal `New order` alert follows, with one extra line for a +still-unpaid COD order: + +``` +New order #12 — Meja 4 +BAR: 2× Kopi Susu Gula Aren, 1× Americano +COD — collect Rp 45.000 at pickup +``` + - The **number** is the same daily transaction number already printed on the slip and shown on the guest's own order-status page — whatever the staff member calls out, it's what everyone else is already looking at. - The line after it is grouped **Bar, then Kitchen** — a coffee-only order still says `BAR: …` so - reading "not mine" takes the same half-second as reading "mine." A `Cash order` alert leads with - the amount to collect instead, since nothing has to be made yet. + reading "not mine" takes the same half-second as reading "mine." A `Cash order` or `Verify COD + order` alert leads with the amount to collect (or check) instead, since nothing has to be made + yet. ## Setting up a phone @@ -120,7 +146,11 @@ Collect Rp 45.000 at the counter · BAR: 2× Kopi Susu Gula Aren, 1× Americano - **Unregister and log out** — either stops a phone from receiving further orders. - **A cash order-app checkout buzzes twice** — once the instant it's created, so a barista can be at an unstaffed till before the guest walks down, and once more when the cashier actually takes - the payment — see [Order Checkout (QRIS & Cash)](/sales/order-checkout). + the payment — see [Order Checkout (QRIS, Cash & COD)](/sales/order-checkout). +- **A COD checkout buzzes with a distinct "verify this" alert, not the normal `New order` one** — + nothing is made until a barista confirms the guest from their checkout photo; the ordinary + `New order` alert follows once they approve it, carrying an extra line for the still-unpaid + amount. ## For engineers @@ -156,3 +186,12 @@ Collect Rp 45.000 at the counter · BAR: 2× Kopi Susu Gula Aren, 1× Americano trigger": D9 on why an unpaid cash order still notifies, and D8 on the `kind` column that made a second notification per transaction expressible instead of forbidden by the outbox's own unique key +- COD reuses the same outbox and the same `order_paid` kind rather than adding a `cod_approved` + one: a new kind for approval would make `payTransaction` also send `order_paid`, buzzing the bar + twice for one order; a fourth `kds_notifications.kind` value, `cod_verification`, is enqueued the + instant a COD checkout completes (bypassing `ShouldNotify`'s station gate and the staleness + check, same as `cash_pending`), and `BuildKdsPushMessage` (`apps/api/domain/kds_notification_entity.go`) + adds the `COD — collect Rp X at pickup` line to `order_paid` for a transaction that is COD and + still unpaid +- Follow-up design doc: `docs/prd-order-cod-payment.md` — FR-9 on what the KDS hears, and D6 on + why approval reuses `order_paid` instead of a new kind diff --git a/docs-site/sales/order-checkout.md b/docs-site/sales/order-checkout.md index abf2cec3..76a57e51 100644 --- a/docs-site/sales/order-checkout.md +++ b/docs-site/sales/order-checkout.md @@ -1,11 +1,13 @@ -# Order Checkout (QRIS & Cash) +# Order Checkout (QRIS, Cash & COD) ## What it does -[Table ordering](/sales/table-ordering) got a customer from a scanned QR code to a full cart. This closes the loop: the cart page — already showing the guest their items and total — is also the payment page. Its footer button reads **"Bayar dengan QRIS · {total}"**; there's no separate checkout page recapping the same cart first. Tapping it asks the guest's name (prefilled if they've ordered from this phone before) and, when both payment methods are enabled, a choice between them: **"Bayar dengan QRIS"** or **"Bayar dengan Cash di Kasir"**. Choosing QRIS takes them straight to `/orders/{reference}`, a **QRIS QR code** generated on the spot at a URL keyed to that payment — reloading the page, backing out and returning, or reopening a bookmark all show the same live QR until it's paid or expires, instead of losing it. Because the guest is paying on the same phone the QR is displayed on, they can't point its camera at its own screen — so the screen also offers **"Download QR"**, saving the code as an image they open from their banking or e-wallet app's "pay from gallery" flow. +[Table ordering](/sales/table-ordering) got a customer from a scanned QR code to a full cart. This closes the loop: the cart page — already showing the guest their items and total — is also the payment page. Its footer button reads **"Bayar dengan QRIS · {total}"**; there's no separate checkout page recapping the same cart first. Tapping it asks the guest's name (prefilled if they've ordered from this phone before) and, when more than one payment method is enabled, a choice between them: **QRIS**, **Cash**, or **COD**. Choosing QRIS takes them straight to `/orders/{reference}`, a **QRIS QR code** generated on the spot at a URL keyed to that payment — reloading the page, backing out and returning, or reopening a bookmark all show the same live QR until it's paid or expires, instead of losing it. Because the guest is paying on the same phone the QR is displayed on, they can't point its camera at its own screen — so the screen also offers **"Download QR"**, saving the code as an image they open from their banking or e-wallet app's "pay from gallery" flow. Choosing cash sends the guest to the same URL, but showing a **"Bayar di kasir {lokasi}"** instruction screen instead: their daily transaction number as the largest thing on it — the same short number the cashier already reads out and the KDS already prints, easier to say aloud across a counter than the 16-character payment reference underneath it — the amount, the item list, and a countdown to walk downstairs and pay before the order expires. There's nothing to scan and nothing to confirm on the guest's side; paying is the cashier's existing **Pay** flow on the [Transactions](/sales/transactions) list, exactly as it works for a walk-in sale. +Choosing **COD** (Cash on Delivery — a trial covered in full below) asks for one more thing before the order is placed: a photo, taken live through the phone's own camera, of the guest's surroundings at the table. There's no picking a photo from the gallery — only a shutter button — because the photo's whole job is to let a barista confirm the guest is actually in the café before anything is made. + Once the bank confirms payment, that same page flips in place to the order-status view — no second navigation, because it was the order-status page all along: the table label (large — it's how the order finds them, there's no pager and no order number), the guest's name, and what they paid for. A `Transaction` now exists for that order, marked paid, showing up on the POS exactly like a cashier-entered sale — badged **"Order App"** and filterable by source — with the right table attached so staff know where to carry it. That order-status page doesn't stop at "paid" — it keeps watching the order through to pickup. See [Order Fulfilment Status, After Payment](#order-fulfilment-status-after-payment) below. And that page isn't the only way back to this order — every paid order from this session shows up in [Order History](/sales/order-history), one tap away from a header button on every order-app screen. @@ -28,13 +30,23 @@ Closing the tab instead of pressing Back is covered too: the cart itself now kno Cancelling is safe against the guest actually having paid: the server re-checks with DOKU before it lets a QRIS cancellation go through, and a cashier can no longer collect cash for an order the guest has walked away from — that pending cash order also drops off the [Kitchen Display System](/sales/kds)'s "waiting at the till" list the moment it's cancelled. If the money turns out to be real after all — a guest cancels, then pays the saved QR PNG anyway — the payment that actually got the money wins, and any newer order made in the meantime is cancelled in its place rather than double-charging. +## Cash on Delivery (COD) + +COD is a trial third payment method, sitting next to QRIS and cash: the guest still pays cash at the counter, exactly like today's cash flow, but the order is only **made** once a barista has confirmed — from the live camera photo taken at checkout — that the guest is actually in the café. It exists for guests whose banking app won't cooperate with QRIS and who don't want to walk to the till before eating, without opening the door to an order nobody ever collects. + +After the camera step, the guest lands on the same `/orders/{reference}` page as any other method, but showing **"Menunggu konfirmasi barista…"** instead of a QR or a countdown to the till — their transaction number, the item list, and a note that the order is made only once a barista confirms, with the same window (default 15 minutes) that a busy bar has to look at the photo before it's cancelled automatically and the guest's cart is preserved. The barista does that confirming from the POS — see [Verifying a COD Order](/sales/transactions#verifying-a-cod-order) — and the moment they do, the guest's page flips itself to the ordinary **preparing** screen, the same one a paid QRIS or cash order shows, with one addition: a banner reading **"Bayar Rp {jumlah} di kasir saat mengambil pesanan"**, since the money hasn't moved yet. That banner stays through **preparing** and **ready** and disappears the instant the cashier takes the payment through the same **Pay** flow as any other sale — there is no separate "confirm COD payment" screen. If the barista instead can't confirm the guest is there, the page shows **"Pesanan ditolak"** and sends the guest back to the cart with everything still in it; no WhatsApp message is sent for a rejection; the guest's own live status page already says so. + +Cash is hidden by configuration during the trial (`NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED=false`), not deleted — from the guest's point of view COD is a superset of cash (still paid at the counter, just verified first), and ending the trial in either direction is an environment change and a redeploy, not a release. + ## Key capabilities - **QRIS, generated per order** — a fresh, amount-locked dynamic QR from DOKU for exactly what the cart totals at the moment of checkout; a later price edit by staff can never change what the guest is being asked to pay. - **Save-and-pay-from-gallery** — the guest is paying on the same device showing the QR, so "Download QR" downloads it as a PNG for their banking app's QRIS-from-gallery flow, with a long-press fallback on older iOS Safari. - **One QR per cart** — a double-tapped pay button or a reloaded screen returns the existing pending QR rather than minting a second one. - **The QR — or the cash instructions — lives at a URL, not in memory** — `/orders/{reference}` is a durable, reloadable page for that one payment; reloading, backing out and returning, or reopening a bookmark all show the same pending QR or cash instructions (or the paid outcome, once it's paid) instead of stranding the guest back at an empty cart. -- **A second payment method, picked in the same sheet** — "Bayar dengan Cash di Kasir" sits next to "Bayar dengan QRIS" behind `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED`; with it off, the sheet is byte-for-byte the QRIS-only original. +- **Extra payment methods, picked in the same sheet** — cash and COD both sit next to QRIS in the same method picker, each behind its own flag (`NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED`, `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED`); with both off, the sheet is byte-for-byte the QRIS-only original. +- **COD's camera step never offers a gallery** — a live `getUserMedia` viewfinder only, with a shutter button; a denied permission or an unsupported browser tells the guest to use QRIS instead, never a file picker. +- **The server gate is authoritative for COD** — `ORDER_COD_PAYMENT_ENABLED` on the API stops an old tab, a cached bundle, or a direct call from creating a COD order (and the photo that comes with it) once the trial flag is off; the frontend flag only hides the option. - **The cashier's existing Pay flow is the cash gateway** — no separate "confirm this app order" screen exists; paying a cash order-app transaction from the [Transactions](/sales/transactions) list settles the payment and flips the guest's screen automatically. - **A cash order buzzes the kitchen twice** — once at checkout, so a barista can walk to an unstaffed till before the guest arrives, and again once it's paid — see [Kitchen Display System (KDS)](/sales/kds). - **Cash orders expire on their own too** — a server-side sweeper, not the guest's phone, cancels an unpaid cash order after a configurable walk-and-queue window (`CASH_PAYMENT_EXPIRY_SECONDS`, default 10 minutes — longer than QRIS's QR-validity window, because the clock is measuring a trip to the counter, not a QR's lifetime); the guest's cart is preserved and reordering is one tap. @@ -73,3 +85,10 @@ Cancelling is safe against the guest actually having paid: the server re-checks - Follow-up design doc: `docs/prd-order-whatsapp-notifications.md` — the WhatsApp number collected at checkout and the order-ready message it triggers, replacing an earlier push-notification opt-in; see [Guest WhatsApp Notifications](/sales/order-notifications) - Follow-up design doc: `docs/prd-order-cash-payment.md` — why cash is a `payments.method` value rather than a new table (D1), why the cashier's existing pay flow is the cash gateway (D11), the separate cash expiry window and why it's twice QRIS's (D5), and why a cash-pending KDS notification bypasses the station rule (D9) - Dining option: the button pair lives in `CustomerDetailsSheet.tsx`, wired through `CartHandler.tsx` to a `CHANGE_DINING_OPTION` action in the `checkout.ts` state machine, carried as `PaymentCheckoutRequest.diningOption` to `PaymentUsecase.Checkout`. See [Transactions](/sales/transactions) for where it shows up on the POS side and `docs/prd-dine-in-takeaway.md` for the full design, including why a retried checkout still updates the reused transaction (D8) +- COD is a `payments.method` value, not a separate entity, settled through the same `PayTransaction` flow as cash — `RequiresGateway()` (`apps/api/domain/payment_entity.go`) keeps DOKU out of it, same as cash +- Camera capture: `libs/ui/src/presentation/views/components/base/CameraCapture/index.tsx` (web `getUserMedia` viewfinder; `index.native.tsx` is an unsupported stub for `pos-mobile`'s Metro bundle) — canvas-downscaled to ≤ 1280px, emitted as base64 JPEG, no file input in any state +- Verification is a second axis, not a new payment status: `payments.verification_status` (`awaiting` \| `approved` \| `rejected`) alongside the existing `status`, so every existing "pending" reader stays correct; `Payment.IsExpirable()` (`apps/api/domain/payment_entity.go`) is the one place that has to ask "approved?" explicitly +- The photo: its own table (`payment_verification_photos`, migration `000044`), hard-deleted in the same DB transaction as the barista's decision — approve, reject, expiry and guest cancel all delete it; a sweeper backstop (`DeleteOrphaned`) logs any orphan at `warn` +- COD checkout branch and flags: `PaymentUsecase.Checkout` (`apps/api/domain/payment_usecase.go`), `ORDER_COD_PAYMENT_ENABLED` and `COD_VERIFICATION_EXPIRY_SECONDS` (default 900s) in `apps/api/utils/env.go`; frontend gate `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED` in `libs/ui/src/app/order/Cart.tsx` +- Frontend state: `CheckoutUsecase` (`libs/ui/src/domain/usecases/checkout.ts`) owns the captured photo (`CAPTURE_PHOTO`/`RETAKE_PHOTO`); `OrderStatusUsecase` (`libs/ui/src/domain/usecases/orderStatus.ts`) adds the `awaitingVerification` state (3s poll), rendered by `CodVerificationView.tsx`; the pay-at-pickup banner is `PayAtPickupBanner.tsx`, shown on `OrderPreparingView`/`OrderReadyView` for an approved-but-unpaid COD order +- Design doc: `docs/prd-order-cod-payment.md` — every COD decision, including why verification is a second axis rather than a new payment status (D2), why the photo lives in MySQL and is hard-deleted (D4), the 15-minute default verification window (D19), and why an uncollected approved order is delivered to the guest's table and paid there rather than written off (D17) diff --git a/docs-site/sales/transactions.md b/docs-site/sales/transactions.md index f152add1..e9910658 100644 --- a/docs-site/sales/transactions.md +++ b/docs-site/sales/transactions.md @@ -8,6 +8,12 @@ Staff search the catalog and add items to a cart, each as a specific **product v Past transactions stay editable — a manager can reopen one later to attach a coupon or fix a line, without re-keying the whole sale. +## Verifying a COD Order + +A [Cash on Delivery](/sales/order-checkout#cash-on-delivery-cod) order arrives on this list already made by the guest, but not yet made by the bar: its row carries an amber **Needs confirmation** badge instead of the usual Preparing/Ready one, and its row menu is trimmed down to a single item, **Verify** — Pay, Mark as Ready, Delete and Print Order Slip are all hidden, since there's nothing to charge or make yet and nothing to hand over if the barista can't confirm the guest is actually there. + +Tapping **Verify** opens a sheet with the guest's name, table, items and total alongside the photo they took at checkout — the same information a barista would want before walking to a table. Two buttons: **Approve**, which turns the order into a normal Preparing one (badged **COD · unpaid** until it's paid) and buzzes the bar to start making it, and **Reject**, which asks for one confirmation ("Reject order #12? The guest's order will be cancelled.") before cancelling the order outright — the guest's own status page immediately shows it was declined, and the row disappears from this list. An order nobody looks at within the confirmation window (15 minutes by default) expires on its own the same way an unpaid cash order does, freeing the guest's cart. Once approved, the order behaves exactly like any other unpaid order-app sale — Pay, Mark as Ready and Print Order Slip all come back, and a guest who never returns to collect it is simply delivered to their table and paid there through the ordinary **Pay** flow. + ## Why it matters This is where the rest of the product earns its keep. A sale here isn't just a till entry — it draws its price and cost straight from the [Catalog](/catalog/categories), and the moment it's paid it feeds the [Finance dashboard](/finance/dashboard-statistics) automatically. Nobody re-enters a sales total into a spreadsheet at the end of the day. @@ -34,6 +40,8 @@ Splitting the receipt into a **customer invoice** and a **station order slip** a - **Two ways to mark an order ready** — **Mark as Ready** from a row's menu, or the primary button on the transaction's detail screen after checking the items against what was made. Both open the same confirmation, and both can be undone with **Mark as Preparing** if it was a mis-tap. Marking an order ready is what flips the guest's own status page to the pickup screen — see [Order Fulfilment Status, After Payment](/sales/order-checkout#order-fulfilment-status-after-payment). - **Refresh without reloading the page** — the refresh button next to **+** on the transaction list pulls the latest transactions for the page you're on, keeping your search, filters and page number. Tap it to pick up orders guests just placed from the [Order App](/sales/order-checkout) instead of reloading the browser. - **Filter the list down to what's outstanding** — the same filter popover as Source and Payment Status adds a **Fulfilment** filter (**All** / **Preparing** / **Ready**); picking **Preparing** or **Ready** also switches Source to **Order**, since the filter only ever means something for order-app rows. Filtering to **Preparing** is the list a barista checks before closing the bar — every guest order still owed. +- **A COD order can't be paid, made or deleted before it's verified** — Pay, Mark as Ready and Delete are hidden on its row until a barista approves it from the **Verify** sheet, enforced server-side so the menu is only ever an affordance, never the actual rule. +- **COD · unpaid, at a glance** — once approved, the row carries the usual Preparing/Ready badge plus a **COD · unpaid** one, so a barista can spot which ready orders still need a cashier's Pay before the guest walks off with them. ## For engineers @@ -48,3 +56,7 @@ Splitting the receipt into a **customer invoice** and a **station order slip** a - Design doc: `docs/prd-order-fulfillment-status.md` — why fulfilment is a nullable `completed_at` timestamp rather than an enum (D1), why it's scoped to `source = 'order'` (D2), and how the same data serves a future kitchen display with no migration (D21) - Dining option: the form control is `SegmentedControl` (`libs/ui/src/presentation/views/components/base/Form/SegmentedControl.tsx`), bound to `transactionFormSchema.diningOption`; the list badge is `TakeawayBadge` in `TransactionListItem.tsx`. Stored as `transactions.dining_option` (`dine_in` \| `takeaway`, `NOT NULL DEFAULT 'dine_in'`), set by `Repository.CreateTransaction` when a client omits it. - Design doc: `docs/prd-dine-in-takeaway.md` — why the value lives on `transactions` rather than the cart or payment (Option C), why an update can't silently reset it back to dine-in (D6), and why only the non-default option is ever shown (D12) +- COD badges and menu guards: `TransactionListItem.tsx`'s `NeedsConfirmationBadge`/`CodUnpaidBadge` and its `menus` array, keyed off `paymentMethod === 'cod' && paymentVerificationStatus === 'awaiting'` +- Verify sheet and its state machine: `TransactionVerificationSheet.tsx` (`libs/ui/src/presentation/views/components/transactions/`), driven by `TransactionVerificationUsecase` (`libs/ui/src/domain/usecases/transactionVerification.ts`, `hidden → loading → shown → approving \| confirmingReject → rejecting → success \| error`), wired in `TransactionListHandler.tsx` +- Backend: `PaymentVerificationUsecase` (`GetVerification`, `Approve`, `Reject` — `apps/api/domain/payment_verification_usecase.go`), routes `GET/PUT /transactions/{id}/verification[/approve\|/reject]` (`apps/api/presentation/restapi/payment_verification_{handler,route}.go`), all staff-only; the photo itself is never in a guest-facing or list response (D14) +- Design doc: `docs/prd-order-cod-payment.md` — the POS-side FR-12, including why Pay and Mark-as-Ready are refused server-side while `awaiting` (D9), and why an uncollected approved order is delivered to the guest's table rather than written off (D17) diff --git a/docs/prd-order-cod-payment.md b/docs/prd-order-cod-payment.md new file mode 100644 index 00000000..b0ec5148 --- /dev/null +++ b/docs/prd-order-cod-payment.md @@ -0,0 +1,727 @@ +# PRD: Order App — Cash on Delivery (COD), Verified by a Photo + +**Status:** Draft for review — all open questions resolved (see [Resolved questions](#resolved-questions)) +**Scope:** a third order-app payment method — **Cash on Delivery (COD)**: the guest proves they are in the café with a live camera photo, a barista verifies it, the order is made *before* payment, and the guest pays at the counter when they pick it up. Shipped behind a trial feature flag, with the existing cash-at-the-cashier method hidden so the guest sees exactly two options: **QRIS** or **COD**. +**Extends, does not supersede:** `docs/prd-order-cash-payment.md` (cited as *Cash D*), `docs/prd-order-checkout-qris-doku.md` (*QRIS D*), `docs/prd-order-payment-cancellation.md` (*Cancel D*), `docs/prd-order-fulfillment-status.md` (*Fulfil D*), `docs/prd-kds-order-notifications.md` (*KDS D*), `docs/prd-order-whatsapp-notifications.md` (*WA D*). + +--- + +## Revision note (first review pass) + +All four open questions were answered in review. They are recorded as new decisions (D17–D20) and a [Resolved questions](#resolved-questions) section rather than edited into the originals: + +1. **An approved COD order the guest never collects is delivered to their table, and paid there** (D17). No new "abandon" path and no change to `DeleteTransactionById` — the first draft's recommended follow-up PR is dropped. +2. **No order-value cap for the trial** (D18). +3. **The verification window is 15 minutes, not 10** (D19, amending D8's default): `COD_VERIFICATION_EXPIRY_SECONDS=900`. +4. **No WhatsApp message on rejection** (D20) — the guest's status page is the only channel. + +None of the four changes the phase count, the dependency graph, or any phase's file list; only phase 4's default value, phase 11's copy and phase 14's production env move. + +--- + +## System Design Overview + +Read this section first; everything after it is the argument for it. + +### The path, end to end + +``` + Guest (order app, at the table) Barista (POS web / pos-mobile) + │ taps Checkout on /t/{code}/cart + ▼ + details sheet: name · WhatsApp · dining option · method (FR-8) + │ [ QRIS ] [ COD ] ← cash hidden by flag (FR-11) + │ + └── COD ─► live camera viewfinder (getUserMedia, rear camera) (FR-7) + guest photographs their surroundings → preview → "Pakai foto ini" + client downsizes to ≤1280px JPEG (~150–300 KB) + │ + │ POST /carts/current/checkout + │ { customerName, …, method: "cod", verificationPhoto: "" } + ▼ + PaymentUsecase.Checkout — COD branch (FR-2) + │ inside BeginTransaction: + ├─ price cart, reserve availability, CreateTransaction (unpaid, source='order') + ├─ INSERT payments (method='cod', status='pending', + │ verification_status='awaiting', + │ expired_at = now + COD_VERIFICATION_EXPIRY_SECONDS) + ├─ INSERT payment_verification_photos (payment_id, bytes) (FR-3) + └─ EnqueueForTransaction(kind='cod_verification') (FR-9) + COMMIT ──► TriggerDispatch + │ │ + ▼ ▼ + /orders/{reference} KDS push: "Verify COD order #12 — Meja 4" + "Menunggu konfirmasi barista…" POS list row: [Order] [Needs confirmation] + (polls every 3 s) │ + │ row menu → Verify (FR-12) + │ sheet shows the photo, table, items, total + │ ┌──────────────┴──────────────┐ + │ Approve Reject + │ │ │ + │ PaymentVerificationUsecase.Approve …Reject (FR-4) + │ verification_status='approved' finalizeUncollectedPayment( + │ DELETE photo cancelled, reason='rejected') + │ cart → converted ├ transaction soft-deleted + │ Enqueue(kind='order_paid') ← "start making" ├ availability released + │ │ └ DELETE photo + ▼ ▼ │ + "Sedang disiapkan · bayar Rp 45.000 KDS push: "New order #12" ▼ + di kasir saat mengambil" barista prepares guest: "Pesanan ditolak" + │ │ + │ row menu → Mark as Ready (existing) + │ │ CompleteTransaction → WhatsApp outbox + ▼ ▼ + "Siap diambil! Bayar Rp 45.000" Fonnte WhatsApp: "Pesanan siap… Bayar di kasir (COD)" + │ │ + guest walks to the counter, shows #12 │ + │ row menu → Pay (existing TransactionPaymentAlert, + │ wallet chosen by the barista) + │ TransactionUsecase.PayTransaction + │ ├ payTransaction(): wallet, income, paid_at + │ │ (its order_paid enqueue is a no-op — already sent) + │ └ settleOrderPayment(): payments.status='paid' + ▼ ▼ + done row: [Order] [Ready] [Paid · Cash] +``` + +**The shape worth noticing:** COD adds exactly one new human step — *verify* — and one new kind of data — *a photo that lives only until that step*. Everything after approval is machinery that already exists: `Mark as Ready` (Fulfil FR-6), the WhatsApp ready message (WA PRD), and the cashier's `Pay` flow with `settleOrderPayment` (Cash FR-6). No new payment gateway, no new transaction column, no new POS pay screen. + +### The two state axes + +A COD payment moves along **two independent axes**, and keeping them separate is the central design decision (D2): + +| Axis | Column | Values for COD | Question it answers | +|---|---|---|---| +| **Money** | `payments.status` (existing) | `pending` → `paid` · or `cancelled` / `expired` | *Has the money been collected?* | +| **Presence** | `payments.verification_status` (**new**) | `awaiting` → `approved` · (`null` for QRIS / cash) | *Has a barista confirmed this guest is in the café?* | + +``` + payments.status = pending status = paid + ┌──────────────────────────────────────────────┐ ┌────────────┐ + checkout ────► │ verification = awaiting ──approve──► approved │─Pay─►│ approved │ + └───────┬───────────────┬──────────────┬───────┘ └────────────┘ + │ reject │ guest cancel │ window elapses (sweeper) + ▼ ▼ ▼ + cancelled (reason=rejected) cancelled (reason=guest) expired + └──────────── transaction soft-deleted · availability released · photo DELETED ───┘ +``` + +An **approved** COD payment is `pending` — no money has moved — but is **never expired and never guest-cancellable** (FR-5): the bar has started making it. + +### How the user's statuses map onto the data + +The acceptance criteria name five transaction statuses. None of them is a new stored column; each is derived from what the row already carries, the same way Fulfil FR-1 derives `preparing` / `ready`: + +| Status in the AC | POS badge | `payments` | `transactions` | Guest screen | +|---|---|---|---|---| +| **Need confirmation** | `Needs confirmation` (amber) | `pending`, `awaiting` | unpaid, `completed_at NULL` | *Menunggu konfirmasi barista…* | +| **Preparing** | `Preparing` + `COD · unpaid` | `pending`, `approved` | unpaid, `completed_at NULL` | *Sedang disiapkan · bayar di kasir saat mengambil* | +| **Ready** | `Ready` + `COD · unpaid` | `pending`, `approved` | unpaid, `completed_at` set | *Siap diambil! Bayar Rp X di kasir* | +| **Success / paid** | `Ready` + paid footer | `paid`, `approved` | `paid_at` set | *Siap diambil* (unchanged) | +| **Rejected** | — (row gone: soft-deleted) | `cancelled`, reason `rejected` | `deleted_at` set | *Pesanan ditolak* | + +### Changed tables + +One migration, `000044_add_payment_verification` (`000043_create_whatsapp_number_verifications` is the latest): + +```sql +ALTER TABLE payments + ADD COLUMN verification_status VARCHAR(16) NULL AFTER status, + ADD COLUMN verified_at DATETIME NULL AFTER verification_status; + +CREATE TABLE payment_verification_photos ( + payment_id BIGINT NOT NULL, + content_type VARCHAR(32) NOT NULL, + byte_size INT NOT NULL, + data MEDIUMBLOB NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (payment_id), + CONSTRAINT fk_payment_verification_photos_payment + FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +**No other schema change.** `payments.method` (`VARCHAR(16)`) takes `'cod'`; `payments.cancel_reason` (`VARCHAR(16)`, migration `000041`) takes `'rejected'`; `kds_notifications.kind` (`VARCHAR(20)`, migration `000035`) takes `'cod_verification'`. All three are values, not migrations — exactly what QRIS D5 and Cash D8 shaped those columns for. + +### New and changed API surface + +| Method | Path | Auth | Change | +|---|---|---|---| +| `POST` | `/carts/current/checkout` | session | `method` enum gains `cod`; new optional `verificationPhoto` (base64 JPEG/WebP), **required iff `method = cod`**; `400` when `ORDER_COD_PAYMENT_ENABLED` is off | +| `GET` | `/payments/{ref}`, `/payments` | session / access key | `Payment` and `PaymentSummary` gain `verificationStatus: awaiting \| approved \| null`; `cancelReason` gains `rejected`. **The photo is never in a guest response.** | +| `GET` | `/transactions`, `/transactions/{id}` | `CheckAuth` | `Transaction` gains `paymentVerificationStatus`, joined exactly as `payment_method` already is (`transaction_repo.go:22`) | +| `GET` | `/transactions/{transactionId}/verification` | `CheckAuth` | **new** — `{ photo: "data:image/jpeg;base64,…", capturedAt }`; `404` once the photo is gone | +| `PUT` | `/transactions/{transactionId}/verification/approve` | `CheckAuth` | **new** — `SuccessResponse` | +| `PUT` | `/transactions/{transactionId}/verification/reject` | `CheckAuth` | **new** — `SuccessResponse` | + +The staff routes hang off `/transactions/{id}` because that is the only identifier the POS holds (it never sees a payment reference), mirroring `/pay`, `/unpay`, `/complete`. + +### Feature flags + +| Variable | Where | Default | Effect | +|---|---|---|---| +| `ORDER_COD_PAYMENT_ENABLED` | `apps/api/.env` | `false` | Server-side gate: a `cod` checkout is `400` when off. Verify / approve / reject stay available so orders in flight can drain after the flag is flipped off (D12). | +| `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED` | `apps/order-web` | `false` | Shows the COD option in the details sheet. | +| `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED` | `apps/order-web` | *(existing)* | Set to `false` for the trial — this is how cash is hidden (D11). | +| `COD_VERIFICATION_EXPIRY_SECONDS` | `apps/api/.env` | `900` | How long an unverified COD order waits for a barista before the sweeper expires it (D8, D19). | + +Trial configuration: API `ORDER_COD_PAYMENT_ENABLED=true`; order-web `NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED=true`, `NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED=false` → the sheet shows **QRIS** and **COD**, nothing else. + +### Changed backend files + +| Layer | File | Change | +|---|---|---| +| Entity | `domain/payment_entity.go` | `PaymentMethodCod`; `PaymentVerificationStatus` (`awaiting`, `approved`); `PaymentCancelReasonRejected`; `Payment.VerificationStatus`, `VerifiedAt`; `IsExpirable()`; `CanBeCancelledBy` refuses an approved payment | +| Entity | `domain/payment_verification_entity.go` | **new** — `PaymentVerificationPhoto`; `ValidateVerificationPhoto(bytes)` (size cap, sniffed content type) | +| Repo iface | `domain/payment_verification_repository.go` | **new** — `Create`, `GetByPaymentId`, `DeleteByPaymentId`, `DeleteOrphaned(ctx, limit)` + `//go:generate mockgen` | +| Use case | `domain/payment_usecase.go` | `Checkout` COD branch (FR-2); `finalizeUncollectedPayment` deletes the photo (FR-6); `refreshPendingCashPaymentStatus` and `expireOne` never expire an approved payment (FR-5) | +| Use case | `domain/payment_verification_usecase.go` | **new** — `GetVerification`, `Approve`, `Reject` (FR-4) | +| Use case | `domain/transaction_usecase.go` | `PayTransaction` refuses a COD payment still `awaiting` (FR-5) | +| Message | `domain/kds_notification_entity.go` | `KdsNotificationKindCodVerification` title/body; `order_paid` body gains a `COD — collect Rp X at pickup` line for an unpaid COD transaction (FR-9) | +| Message | `domain/guest_whatsapp_message.go` | COD label and a pay-at-pickup line (FR-10) | +| MySQL | `data/mysql/payment_{repo,entity,transformer}.go` | new columns; `GetExpirablePayments` excludes `verification_status = 'approved'` | +| MySQL | `data/mysql/payment_verification_repo.go` (+ entity) | **new** | +| MySQL | `data/mysql/transaction_repo.go` | `payments.verification_status AS payment_verification_status` beside the existing `payment_method` select | +| REST | `presentation/restapi/payment_{handler,transformer}.go` | parse/validate `verificationPhoto`; `http.MaxBytesReader` on checkout (none exists today); serialise `verificationStatus` | +| REST | `presentation/restapi/payment_verification_{handler,route,transformer}.go` | **new** — the three staff routes under `CheckAuth` | +| Wiring | `main.go`, `utils/env.go` | the two env vars; the orphan-photo job on the existing maintenance sweeper tick | + +`data/doku/**`, `PaymentGatewayRepository` and `TransactionPaymentAlert` are **not touched** (D1). + +### Changed frontend slice (`libs/ui`) + +``` +domain/entities/Payment.ts PaymentMethod += 'cod'; verificationStatus; PaymentCancelReason += 'rejected' +domain/entities/Transaction.ts paymentVerificationStatus +domain/repositories/payment.ts checkout(…, method, verificationPhoto?) +domain/repositories/transaction.ts fetchVerification / approveVerification / rejectVerification +data/api + data/mock the above, plus COD fixtures +domain/usecases/checkout.ts verificationPhoto in Context; CAPTURE_PHOTO / RETAKE_PHOTO +domain/usecases/orderStatus.ts awaitingVerification state; COD preparing/ready carry payAtPickup +domain/usecases/transactionVerification.ts NEW — hidden → loading → shown → approving|rejecting → … + +presentation/views/components/base/CameraCapture/ NEW — index.tsx (getUserMedia) + index.native.tsx stub +presentation/views/components/checkout/CustomerDetailsSheet.tsx method list from enabledMethods; COD photo step +presentation/views/components/checkout/CodVerificationView.tsx NEW — "Menunggu konfirmasi barista" +presentation/views/components/orderStatus/Order{Preparing,Ready}View.tsx pay-at-pickup banner +presentation/views/components/transactions/TransactionListItem.tsx badges; Verify menu item; guards +presentation/views/components/transactions/TransactionVerificationSheet.tsx NEW — photo + Approve / Reject +presentation/handlers/order/{Cart,OrderStatus}Handler.tsx +presentation/handlers/pos/TransactionListHandler.tsx +app/order/Cart.tsx reads NEXT_PUBLIC_ORDER_COD_PAYMENT_ENABLED → enabledMethods +app/pos/TransactionList.tsx constructs TransactionVerificationUsecase +``` + +--- + +## Problem Statement + +The order app offers two payment methods today, both behind `CustomerDetailsSheet` (`libs/ui/src/presentation/views/components/checkout/CustomerDetailsSheet.tsx:122`): + +- **QRIS** — the guest pays at the table. Good experience; the order goes straight to the bar (`applyQrisStatus` → `payTransaction` → `order_paid` KDS push). +- **Cash at the cashier** (`docs/prd-order-cash-payment.md`) — the guest checks out, then **must walk to the counter immediately** and pay within `CASH_PAYMENT_EXPIRY_SECONDS` (600 s), or `ExpireStalePayments` (`apps/api/domain/payment_usecase.go:513`) cancels the order. Nothing is made until money changes hands, because the KDS `order_paid` notification is enqueued only inside `payTransaction` (`apps/api/domain/transaction_usecase.go:359`). + +For a guest who does not want to or cannot pay by QRIS, cash turns "order from your table" back into "queue at the counter" — the exact interaction the order app exists to remove. They then walk to the counter a **second** time to collect their drink. Two trips, one of them to stand in line while nothing is being made. + +### Root cause + +The system ties **"start making it"** to **"money collected"**. For a gateway payment that is the right rule: the money is certain before the bar lifts a finger. For a guest paying cash it forces the payment trip to happen *before* preparation, so the guest pays and then waits, instead of waiting at the table and paying on pickup. + +Café staff already know how to make an unpaid order safely — they do it every time a known guest says "I'll pay when I pick it up". The risk they manage is **the guest who is not actually here** (a prank order from outside, a guest who has already left). What the system is missing is a way for staff to make that judgement from the POS: evidence that the guest is in the café, a decision, and then the ordinary prepare → ready → pay flow — with the payment step *last*. + +--- + +## How comparable products handle this + +- **"Bayar di kasir" / pay-later in Indonesian QR-ordering products** (the category this app follows, see Fulfil PRD Sources) is common for dine-in: the order goes to the kitchen and the bill is settled at the counter or at the table on the way out. Their fraud control is the *table* — a QR bound to a physical seat. +- **Delivery COD** (GoFood / GrabFood cash) verifies the *customer's account history* and caps order value; the courier bears the no-show risk. +- **Proof-of-presence by photo** is used by attendance and field-sales apps (a selfie or surroundings photo taken in-app, never from the gallery), reviewed by a human or by geofencing. + +This design combines the first and third: the table QR already implies presence, and the live photo plus a human decision closes the gap the table QR leaves open (a QR photographed and used from outside, or a guest who left). A value cap, borrowed from delivery COD, was considered and deliberately left out of the trial (D18). + +> **Verification note.** As in Cash PRD's equivalent section, no vendor documentation was fetched while writing this; the paragraph is general familiarity with the category and the design does not depend on it describing any specific product accurately. No Sources section is offered rather than a fabricated one. + +--- + +## Alternatives Considered + +### 1. How the verification state is modelled + +**Option A — an orthogonal `payments.verification_status` column; the money state stays `pending` until paid. (Recommended)** +- ✅ True to the data: an approved COD order *is* uncollected money. `pending` is the honest money state, and every consumer of "pending" keeps meaning the right thing — `settleOrderPayment` still flips it to `paid` (`transaction_usecase.go:283`), order history still lists it (`paymentHistoryFilter`, `payment_repo.go:76`), idempotency still finds it (`GetPendingPaymentByCartId`). +- ✅ Rejection reuses the existing terminal shape: `cancelled` + `cancel_reason = 'rejected'`, through `finalizeUncollectedPayment` — release, soft-delete, done. +- ✅ `null` for QRIS and cash, so no existing row changes meaning. +- ❌ Two places carry the COD lifecycle (status + verification). Every "is this pending payment expirable / cancellable?" check has to ask the second column too — FR-5 names the three that must. + +**Option B — new values on `payments.status`: `awaiting_verification`, `approved`, `rejected`.** +- ✅ One column, one state machine. +- ❌ Every one of the dozen `pending` reads in the payment path (idempotency, cart lock via `IsAwaitingPayment`, history, sweeper, cancel, settle, the frontend's `stateTypeForPayment` `match(...).exhaustive()`) must be audited and most widened to `IN (...)`. Missing one is a silent bug — e.g. a cart that stops freezing. +- ❌ `approved` would sit in a column that otherwise answers "where is the money", while no money has moved. + +**Option C — the state on `transactions` (a `verification_status` there).** +- ✅ The POS already reads transactions. +- ❌ A verification is a property of the *guest's payment attempt*, and it must be visible on the guest's `/payments/{ref}` read — which is a payment read. It would also put a COD-only column on every POS and rental row (Fulfil D22's "never read without a source guard" trap, again). + +### 2. Where the photo lives + +**Option A — a dedicated MySQL table, `payment_verification_photos`, `MEDIUMBLOB`, hard-deleted on decision. (Recommended)** +- ✅ **Atomic with the decision.** Approve, reject, cancel and expiry already run inside `BeginTransaction`; the `DELETE` joins that transaction, so "decided but photo still stored" cannot exist — the AC's storage promise is enforced by the database, not by a best-effort cleanup. +- ✅ No new infrastructure on a single-VPS deployment (`docs/trd-vps-deployment-automation.md`), no new credentials, no new backup target. +- ✅ A separate table keeps ~250 KB blobs out of every `SELECT payments.*` — the guest status poll and the history list never touch it. +- ❌ BLOBs in MySQL are usually a smell. Here the volume is bounded by *orders awaiting verification right now* (typically 0–5 rows, each ≤ 1 MiB) because every row is deleted within `COD_VERIFICATION_EXPIRY_SECONDS`; InnoDB reuses freed pages. It is a queue, not an archive. + +**Option B — files on the VPS disk (`/var/lib/gatherloop-pos/cod/…`).** +- ✅ Idiomatic for binary data. +- ❌ Not transactional: a crash between the DB commit and `os.Remove` leaves an orphan file; the reverse leaves a row pointing at nothing. Needs a reconciler to keep the AC's promise. +- ❌ New operational surface: a directory, permissions under the systemd unit (`apps/api/gatherloop-pos-api.service`), and a path the next deploy must preserve. + +**Option C — object storage (S3 / R2 / GCS).** +- ✅ Scales without thought; lifecycle rules can auto-delete. +- ❌ A new vendor, credentials and network dependency on the checkout path — for a trial whose steady state is a handful of short-lived images. + +### 3. How "camera only, never the gallery" is enforced + +**Option A — an in-app live viewfinder via `navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })`, captured to a canvas. (Recommended)** +- ✅ The only browser mechanism that *guarantees* the image came from the camera at that moment — there is no file picker in the flow at all, so there is no gallery to pick from. +- ✅ The canvas step downsizes and re-encodes (≤ 1280 px, JPEG q≈0.7), which both caps upload size and strips EXIF (including GPS) for privacy. +- ❌ Requires a camera permission prompt, HTTPS (already true) and a supported browser. In-app browsers (WhatsApp, Instagram) and very old WebViews may not support it — FR-7 falls back to "use QRIS", never to the gallery. + +**Option B — ``.** +- ✅ Zero UI to build; opens the native camera on most phones. +- ❌ `capture` is a *hint*: several Android browsers show a chooser including the gallery, and desktop ignores it entirely. It fails the AC as written ("must be camera, not pick up from gallery"). + +**Option C — geolocation / Wi-Fi-based presence instead of a photo.** +- ✅ No human review. +- ❌ Not what the AC asks for; browser geolocation indoors is tens of metres off, and a permission prompt for location is a harder sell than for a camera. Recorded as a possible *complement* (Out of Scope). + +### 4. How the photo reaches the API + +**Option A — base64 inside the existing checkout JSON. (Recommended)** +- ✅ One request, one DB transaction: the order and its evidence are created together or not at all. No orphan uploads. +- ✅ Stays inside the OpenAPI-generated JSON clients on both sides; no multipart handling in the Go handlers or the TS client. +- ❌ ~33% encoding overhead — ~300 KB becomes ~400 KB. Irrelevant at one photo per order. + +**Option B — `multipart/form-data` checkout.** ❌ Changes the content type of an existing endpoint every client uses, and the generated clients handle multipart awkwardly. + +**Option C — upload first (`POST /verification-photos` → id), then checkout with the id.** ❌ Two requests, and a guest who uploads then abandons leaves an orphan the system must garbage-collect — the very storage problem the AC forbids. + +--- + +## Proposed Solution + +### Acceptance criteria → requirements + +| AC | Requirement | +|---|---| +| Customer must open the camera (not gallery) and photograph their surroundings | FR-7, FR-8 | +| A transaction is created with payment method COD and status "need confirmation" | FR-1, FR-2, FR-12 | +| Barista opens the transaction item menu and chooses Verify | FR-12 | +| The image is shown with Approve and Reject | FR-4, FR-12 | +| Approved → preparing; rejected → rejected, payment and transaction deleted | FR-4, FR-13 | +| After approval the barista prepares the order | FR-4, FR-9 | +| Mark ready → WhatsApp sent → customer picks up and pays | FR-10 (existing ready flow) | +| Barista pays (chooses wallet) → transaction success and paid | FR-5 (existing pay flow + guard) | +| Verification image removed after it is confirmed (approved or rejected) | FR-3, FR-4, FR-6 | +| Trial feature flag; cash hidden so only QRIS and COD are shown | FR-11 | + +### FR-1 — COD crosses the contract (API) + +- `PaymentCheckoutRequest.method` enum becomes `[qris, cash, cod]`; `ParsePaymentMethod` accepts `cod`. +- `PaymentCheckoutRequest.verificationPhoto` — optional `string` (base64, no data-URL prefix). The **handler** rejects `method = cod` without it, and any other method *with* it, with `400` — the use case never sees an inconsistent pair. +- `Payment.verificationStatus`, `PaymentSummary.verificationStatus` — `enum [awaiting, approved]`, nullable. `cancelReason` enum gains `rejected`. +- `Transaction.paymentVerificationStatus` — same enum, nullable, `null` for POS rows. +- `Payment.RequiresGateway()` is unchanged (`method == qris`), so COD takes the non-gateway branch everywhere cash does — expiry on the clock, no DOKU query — without a new condition (Cash D2's "fail closed" rule doing its job). + +### FR-2 — The COD checkout branch (API) + +`PaymentUsecase.Checkout` keeps every step before payment creation. For `method = cod`: + +| Step | Behaviour | +|---|---| +| Gate | `ORDER_COD_PAYMENT_ENABLED` off ⇒ `400 "payment method is not available"` before any write. | +| Photo | `ValidateVerificationPhoto` — decoded ≤ `codVerificationPhotoMaxBytes` (1 MiB), `http.DetectContentType` ∈ {`image/jpeg`, `image/webp`}; else `400`. Runs **before** `BeginTransaction`, like the WhatsApp check (WA-validation D5). | +| Payment | `status = pending`, `verification_status = awaiting`, `expired_at = now + COD_VERIFICATION_EXPIRY_SECONDS`, `qr_content = ''`. | +| Photo row | `PaymentVerificationRepository.Create` inside the same transaction. | +| KDS | `EnqueueForTransaction(kind = cod_verification)` — see FR-9 — and `TriggerDispatch` after commit, as the cash branch does (`payment_usecase.go:279`). | +| Idempotency | Unchanged and method-agnostic (QRIS D11): a live pending payment on the cart is returned as-is, **and its stored photo is not replaced** — the barista verifies the photo the order was created with. | + +The checkout route gains `http.MaxBytesReader` at 2 MiB. Nothing in `presentation/restapi` bounds a request body today; the photo is what makes that matter. + +### FR-3 — The photo store (API) + +`payment_verification_photos` (see migration) behind `PaymentVerificationRepository`: + +```go +Create(ctx, photo PaymentVerificationPhoto) *Error +GetByPaymentId(ctx, paymentId int64) (PaymentVerificationPhoto, *Error) +DeleteByPaymentId(ctx, paymentId int64) *Error // NotFound is not an error +DeleteOrphaned(ctx, limit int) (int64, *Error) // photos whose payment is not pending+awaiting +``` + +**Invariant (D5): a photo row exists if and only if its payment is `cod`, `pending` and `awaiting`.** Every transition out of that state deletes the photo in the same DB transaction (FR-4, FR-6). `DeleteOrphaned` is a backstop, run on the existing maintenance sweeper tick (`KDS_DISPATCH_INTERVAL_SECONDS`), that should always delete zero rows; a non-zero count is logged at `warn` because it means some path broke the invariant. + +The photo is **never** logged, never included in a guest response, and never included in a `Transaction` or list response — only `GET /transactions/{id}/verification` returns it. + +### FR-4 — Verify, approve, reject (API) + +New `PaymentVerificationUsecase` in `domain/payment_verification_usecase.go`. All three methods resolve the payment with `GetPaymentByTransactionIdForUpdate` — the same row lock `PayTransaction` takes (`transaction_usecase.go:236`), so approve, reject, a guest cancel and the sweeper serialise instead of racing (Cancel D6). + +**`GetVerification(ctx, transactionId)`** → `{ photo, capturedAt }`. `404` if there is no linked payment, it is not COD, or the photo is gone (already decided). + +**`Approve(ctx, transactionId)`** — guards: linked payment exists, `method = cod`, `status = pending`, `verification_status = awaiting`; otherwise `400` naming the state ("order already verified", "order was cancelled"). Then, in one transaction: +1. `verification_status = approved`, `verified_at = now`; +2. `DeleteByPaymentId` — the AC's storage promise; +3. cart → `converted`, so the guest can start a new order while this one is made (the cart was frozen since checkout, Cash D12); +4. `EnqueueForTransaction(kind = order_paid)` — the bar's existing "start making it" signal (D6); +5. `TriggerDispatch` after commit. + +A payment whose `expired_at` has passed but which the sweeper has not yet reached (≤ 15 s) **is still approvable**: the lock makes it a clean race, and the barista's decision beats a sweeper that is late by a tick (Cash D23's spirit). + +**`Reject(ctx, transactionId)`** — same guards. Then `finalizeUncollectedPayment(ctx, payment, PaymentStateCancelled, &PaymentCancelReasonRejected, …)`, which already moves the payment to `cancelled`, releases availability and soft-deletes the transaction (`payment_usecase.go:418`) — and, from FR-6, deletes the photo. `verified_at = now` is set as well so the decision time is recorded. No KDS retraction: the `cod_verification` push asked a barista to look, and a barista just did. + +"All payment and transaction will be deleted" in the AC is implemented as the codebase's existing meaning of deleted: the transaction is **soft-deleted** (absent from every POS list and report, `deleted_at` set) and the payment reaches a terminal state. Hard-deleting them would break the guest's status page — which must be able to say *"ditolak"* — and the audit trail every other terminal path keeps (D7). + +### FR-5 — The lifecycle guards an approved order needs (API) + +An approved COD payment is `pending` (Option A) but must behave as *committed*: + +| Path | Today, for any pending non-gateway payment | For COD | +|---|---|---| +| Sweeper `GetExpirablePayments` (`payment_repo.go:117`) | expires when `expired_at < now` | adds `AND (verification_status IS NULL OR verification_status <> 'approved')` | +| Guest poll `refreshPendingCashPaymentStatus` (`payment_usecase.go:832`) | expires on the clock | returns unchanged when `!payment.IsExpirable()` | +| Guest cancel `CanBeCancelledBy` (`payment_entity.go`) | any pending payment of the session | `false` once `approved` — the bar is making it | +| Cashier pay `PayTransaction` (`transaction_usecase.go:224`) | pays any unpaid transaction | `400 "verify the order before taking payment"` while `awaiting` | +| `Mark as Ready` `CompleteTransaction` | any order transaction | `400` while `awaiting` — nothing should be made before approval | + +`Payment.IsExpirable()` = `status == pending && verification_status != approved`, and is the single predicate the domain-side paths above ask. + +Paying an approved COD order runs the existing path unchanged: `payTransaction` credits the wallet the barista chose, and its `order_paid` enqueue is a no-op because approval already inserted that `(transaction_id, kind)` row (KDS D4 / Cash D8's unique key) — so paying does **not** buzz the bar a second time. `settleOrderPayment` flips the payment to `paid`; the cart is already converted, which it tolerates. + +### FR-6 — Every terminal path deletes the photo (API) + +`finalizeUncollectedPayment` gains the `PaymentVerificationRepository` and calls `DeleteByPaymentId` for `method = cod`. That one edit covers reject (FR-4), guest cancel (`CancelPayment`), sweeper expiry (`expireOne` → `expirePayment`), guest-poll expiry, and supersede (`supersedeLivePayments`) — every way a COD order can end without approval — because they already share this function (Cash FR-4's "one definition of what giving up does"). + +### FR-7 — Camera capture (frontend) + +A new base component `presentation/views/components/base/CameraCapture/` with the platform split already used by `base/Chart/index.native.ts`: + +- `index.tsx` (web): requests `getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false })`, renders a live `